Compare commits

...

72 Commits

Author SHA1 Message Date
Fredrik Burmester
7f07260177 fix: hide setting (use only vlc3 for now) 2025-03-03 15:18:09 +01:00
herrrta
09e9462ac0 feat: (iOS) Switch Video Players 2025-03-03 01:12:08 -05:00
herrrta
dd65505f7f feat: [Jellyseerr] Show recent requests #324
- Added recent requests slide
- updated JellyseerrPoster.tsx to handle more options
2025-03-03 01:04:49 -05:00
herrrta
951158bcd3 fix: Discover page key collisions #581
- add uniqBy for jellyseerr results
- add missing key in MovieTvSlide.tsx
2025-03-02 13:07:14 -05:00
herrrta
9b1dd0923a fix: Don't show all seasons numbers in request modal [Jellyseerr] #580 2025-03-02 12:38:58 -05:00
herrrta
bd908516b5 fix: Advanced request options not saving #543 2025-03-02 12:21:24 -05:00
lostb1t
8cb10d1062 Update _layout.tsx 2025-03-01 09:37:50 +01:00
lostb1t
446439c2e0 Update package.json 2025-02-28 00:11:03 +01:00
lostb1t
a5463d783d fix: use correct url on save for optimized 2025-02-26 19:25:20 +01:00
Little709
640db35456 fix: Update nl.json (#565) 2025-02-26 14:21:42 +01:00
Simon Eklundh
caa4b765c1 fix: makes the icon adaptive for android (#569) 2025-02-26 08:23:27 +01:00
sarendsen
9c6aebe66a small cleanup 2025-02-24 18:41:07 +01:00
sarendsen
ef42510383 small cleanup 2025-02-24 14:56:39 +01:00
sarendsen
5273dfd22b small cleanup 2025-02-24 14:23:48 +01:00
Fredrik Burmester
00bc4232fb fix: xcode warnings 2025-02-24 11:51:48 +01:00
sarendsen
35c9258062 fix: playback pause/play reporting 2025-02-24 10:30:01 +01:00
sarendsen
89bf51c3cc fix: playback reporting 2025-02-24 09:30:14 +01:00
sarendsen
f64c5a02db fix: add hw/sw badge to session 2025-02-23 19:15:10 +01:00
sarendsen
cf284eb3d8 fix: sort sessions by name 2025-02-23 18:42:00 +01:00
Alex
b581a077e1 General refactoring (#559) 2025-02-23 09:40:10 -05:00
Fredrik Burmester
e651b975b7 fix: ts error 2025-02-23 15:08:14 +01:00
lostb1t
1c550b1b77 feat: Mark/unmark favorite quick action (#561) 2025-02-23 15:03:52 +01:00
Ahmed Sbai
5bcae81538 fix(511): fixed long named translations for the subtitle can break UI (#564) 2025-02-23 15:03:22 +01:00
Fredrik Burmester
c951725222 chore 2025-02-23 15:02:50 +01:00
Fredrik Burmester
0b966d7c04 fix: add hevc to chromecast h265 profile 2025-02-23 14:50:14 +01:00
Fredrik Burmester
8e0e35afe3 fix: chromecast 2025-02-23 14:35:00 +01:00
Fredrik Burmester
daf7f35196 feat: scroll to top on tab home press 2025-02-22 13:23:33 +01:00
Fredrik Burmester
d5ac30b6d8 feat: scroll to top on tab home press 2025-02-22 13:20:52 +01:00
Fredrik Burmester
81b91bbb97 chore 2025-02-22 13:11:37 +01:00
Fredrik Burmester
af2bd030e9 feat: focus search bar on second tab press (#558) 2025-02-22 13:09:17 +01:00
Fredrik Burmester
5590c2f784 fix: added season and episode + updated icon 2025-02-22 12:08:58 +01:00
Fredrik Burmester
6cc70dd123 fix: type issues 2025-02-22 12:02:33 +01:00
Edmond
fae588b0f0 fix: Improve Chinese (Traditional) Translation (#557) 2025-02-22 11:06:43 +01:00
vuhe
bd2aeb2234 feat: Add Chinese (Simplified) Translation (#556) 2025-02-22 11:06:10 +01:00
sarendsen
cca0bbf42c bigger play button 2025-02-22 10:58:28 +01:00
Fredrik Burmester
06e0eb5c4e Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-02-21 20:38:45 +01:00
Fredrik Burmester
b478fbb6bf fix: tvos fixes 2025-02-21 20:38:31 +01:00
lostb1t
b98a7b0634 Update _layout.tsx 2025-02-21 18:22:05 +01:00
lostb1t
ce38024a3f Update settings.tsx 2025-02-21 14:57:53 +01:00
lostb1t
04dce9265b Update _layout.tsx 2025-02-21 14:56:59 +01:00
lostb1t
5b8418cd82 feat: Sessions view (#537) 2025-02-21 13:14:57 +01:00
tkymmm
b0c5255bd7 feat: add japanese translations (#552) 2025-02-21 11:09:36 +01:00
Fredrik Burmester
73dd171987 chore: version bump 2025-02-20 16:30:36 +01:00
Ahmed Sbai
ff35559687 fix: remove unused imports and optimize keepAwake usage in the player (#548) 2025-02-20 11:18:37 +01:00
Fredrik Burmester
5aadd50946 chore 2025-02-20 11:16:10 +01:00
herrrta
63b5ba2112 feat: add upcoming air dates for episodes 2025-02-19 21:49:28 -05:00
herrrta
8b955578a2 fix: Jellyseerr url input 2025-02-19 21:07:50 -05:00
herrrta
1e5c021c93 fix: Reset ios vout when media is not playing 2025-02-19 20:58:39 -05:00
Fredrik Burmester
0b86f56486 fix: spelling 2025-02-19 20:36:51 +01:00
Fredrik Burmester
728b93f4e5 fix: up stale issue time to 90 days, ignore feature requests 2025-02-19 20:36:31 +01:00
Fredrik Burmester
2fc483b24e Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-02-19 20:19:39 +01:00
Fredrik Burmester
fc901bc01e fix: jellyseerr login without password 2025-02-19 20:19:34 +01:00
lostb1t
2b0884b154 Update README.md 2025-02-19 17:14:01 +01:00
lostb1t
307d20e538 Update README.md 2025-02-19 17:13:12 +01:00
Fredrik Burmester
a2f03908f6 fix: remove splashscreen provider and handle loading in jellyfinprovider 2025-02-19 14:44:39 +01:00
Fredrik Burmester
77aef8877e fix: jellyseerr better login 2025-02-19 11:36:47 +01:00
Fredrik Burmester
0cf930d6e1 test: fix controls loading android? 2025-02-19 11:06:57 +01:00
Fredrik Burmester
4b0b949541 fix: button alignment pip 2025-02-19 10:55:29 +01:00
Fredrik Burmester
14b717f985 fix: half screen black on login 2025-02-19 10:54:05 +01:00
Fredrik Burmester
cfbac538f8 chore: refactor for tv stuff 2025-02-19 10:49:18 +01:00
Fredrik Burmester
1ac6b7e3df fix: chromecast not working 2025-02-18 17:56:10 +01:00
Davide Sirico
c9f6e8676b feat: add italian translations (#545) 2025-02-18 16:10:15 +01:00
Fredrik Burmester
5aab1450cd chore 2025-02-18 16:06:35 +01:00
Fredrik Burmester
1e7080a136 chore 2025-02-18 16:06:32 +01:00
Théo FORTIN
993cec4138 feat: remove stale issues (#515) 2025-02-17 16:54:25 +01:00
Ahmed Sbai
6c524499f9 chore: remove async-storage && moved @types/xxx dependencies to dev-deps (#538) 2025-02-17 16:54:12 +01:00
Maarten
b3463ffdfc feat: add dutch translations (#539)
Co-authored-by: Maarten Schroeven <maarten.schroeven@ae.be>
2025-02-17 16:53:10 +01:00
Edmond
50942b44f1 feat: Add Chinese (Traditional) Translation (#522) 2025-02-17 16:52:58 +01:00
Mustafa
f602f8919f fix: translation de.json grammar (#516) 2025-02-17 16:52:44 +01:00
herrrta
0e86d8a00f fix: IOS video player black screens pt2
- Looks like re-adding subview was not enough. We have to toggle the video tracks selection and play the media to trigger the re-render
2025-02-16 15:05:32 -05:00
Adrián
56b1e1977c fix: Change too long texts in the Spanish translation (#535) 2025-02-16 13:22:13 +01:00
herrrta
30e23b9079 fix: IOS video player black screens
- restores player view when re-entering apps foreground
- added logger
2025-02-15 22:18:16 -05:00
116 changed files with 6465 additions and 2226 deletions

1
.gitattributes vendored Normal file
View File

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

View File

@@ -43,6 +43,7 @@ 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

39
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
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"

3
.gitignore vendored
View File

@@ -10,6 +10,8 @@ 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
@@ -42,3 +44,4 @@ credentials.json
.vscode/ .vscode/
.idea/ .idea/
.ruby-lsp .ruby-lsp
modules/hls-downloader/android/build

View File

@@ -9,6 +9,7 @@
"editor.defaultFormatter": "esbenp.prettier-vscode", "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"
} }

6
Makefile Normal file
View File

@@ -0,0 +1,6 @@
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. 3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
4. run `npm run prebuild` 4. run `npm run prebuild`
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. 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.
For the TV version suffix the npm commands with `:tv`. For the TV version suffix the npm commands with `:tv`.

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.26.1", "version": "0.27.0",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -33,7 +33,9 @@
"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": [

View File

@@ -1,13 +1,18 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather } from "@expo/vector-icons"; import { Ionicons, Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null; const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
import { useAtom } from "jotai";
import { userAtom } from "@/providers/JellyfinProvider";
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
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
@@ -27,13 +32,10 @@ export default function IndexLayout() {
{!Platform.isTV && ( {!Platform.isTV && (
<> <>
<Chromecast.Chromecast /> <Chromecast.Chromecast />
<TouchableOpacity {user && user.Policy?.IsAdministrator && (
onPress={() => { <SessionsButton />
router.push("/(auth)/settings"); )}
}} <SettingsButton />
>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
</> </>
)} )}
</View> </View>
@@ -52,6 +54,12 @@ export default function IndexLayout() {
title: t("home.downloads.tvseries"), title: t("home.downloads.tvseries"),
}} }}
/> />
<Stack.Screen
name="sessions/index"
options={{
title: t("home.sessions.title"),
}}
/>
<Stack.Screen <Stack.Screen
name="settings" name="settings"
options={{ options={{
@@ -112,3 +120,38 @@ export default function IndexLayout() {
</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,498 +1,5 @@
import { Button } from "@/components/Button"; import { HomeIndex } from "@/components/settings/HomeIndex";
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";
type ScrollingCollectionListSection = { export default function page() {
type: "ScrollingCollectionList"; return <HomeIndex />;
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

@@ -0,0 +1,365 @@
import { Text } from "@/components/common/Text";
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
import { FlashList } from "@shopify/flash-list";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Loader } from "@/components/Loader";
import { HardwareAccelerationType, SessionInfoDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import Poster from "@/components/posters/Poster";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { useInterval } from "@/hooks/useInterval";
import React, { useEffect, useMemo, useState } from "react";
import { formatTimeString } from "@/utils/time";
import { formatBitrate } from "@/utils/bitrate";
import {
Ionicons,
Entypo,
AntDesign,
MaterialCommunityIcons,
} from "@expo/vector-icons";
import { Badge } from "@/components/Badge";
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]);
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}
</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,8 +1,9 @@
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 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";
@@ -10,24 +11,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 { clearLogs } from "@/utils/log"; import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic"; import { storage } from "@/utils/mmkv";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import 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"; import { useAtom } from "jotai";
const DownloadSettings = lazy( import { userAtom } from "@/providers/JellyfinProvider";
() => import("@/components/settings/DownloadSettings") import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
);
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");
@@ -62,6 +63,7 @@ export default function settings() {
> >
<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>
@@ -72,12 +74,14 @@ export default function settings() {
<OtherSettings /> <OtherSettings />
{!Platform.isTV && <DownloadSettings />} <DownloadSettings />
<PluginSettings /> <PluginSettings />
<AppLanguageSelector /> <AppLanguageSelector />
<ChromecastSettings />
<ListGroup title={"Intro"}> <ListGroup title={"Intro"}>
<ListItem <ListItem
onPress={() => { onPress={() => {

View File

@@ -38,7 +38,7 @@ export default function page() {
}); });
return await getStatistics({ return await getStatistics({
url: settings?.optimizedVersionsServerUrl, url: updatedUrl,
authHeader: api?.accessToken, authHeader: api?.accessToken,
deviceId: getOrSetDeviceId(), deviceId: getOrSetDeviceId(),
}); });

View File

@@ -42,25 +42,28 @@ const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal"; import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {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, ...result } = const { mediaTitle, releaseYear, posterSrc, mediaType, ...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;
} & Partial<MovieResult | TvResult>; mediaType: MediaType;
} & 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);
@@ -71,7 +74,7 @@ const Page: React.FC = () => {
refetch, refetch,
} = useQuery({ } = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id, enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", result.mediaType, result.id], queryKey: ["jellyseerr", "detail", mediaType, result.id],
staleTime: 0, staleTime: 0,
refetchOnMount: true, refetchOnMount: true,
refetchOnReconnect: true, refetchOnReconnect: true,
@@ -79,7 +82,7 @@ const Page: React.FC = () => {
retryOnMount: true, retryOnMount: true,
refetchInterval: 0, refetchInterval: 0,
queryFn: async () => { queryFn: async () => {
return result.mediaType === MediaType.MOVIE return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!!) ? jellyseerrApi?.movieDetails(result.id!!)
: jellyseerrApi?.tvDetails(result.id!!); : jellyseerrApi?.tvDetails(result.id!!);
}, },
@@ -111,10 +114,15 @@ 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: result.mediaType!!, mediaType: mediaType!!,
tvdbId: details?.externalIds?.tvdbId, tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0) ?.filter?.((s) => s.seasonNumber !== 0)
@@ -122,7 +130,7 @@ const Page: React.FC = () => {
}; };
if (hasAdvancedRequestPermission) { if (hasAdvancedRequestPermission) {
advancedReqModalRef?.current?.present?.(body); setRequestBody(body)
return; return;
} }
@@ -132,7 +140,7 @@ 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) &&
result.mediaType === MediaType.TV, mediaType === MediaType.TV,
[details] [details]
); );
@@ -200,7 +208,7 @@ const Page: React.FC = () => {
<View className="px-4"> <View className="px-4">
<View className="flex flex-row justify-between w-full"> <View className="flex flex-row justify-between w-full">
<View className="flex flex-col w-56"> <View className="flex flex-col w-56">
<JellyserrRatings result={result as MovieResult | TvResult} /> <JellyserrRatings result={result as MovieResult | TvResult | MovieDetails | TvDetails} />
<Text <Text
uiTextView uiTextView
selectable selectable
@@ -247,15 +255,14 @@ const Page: React.FC = () => {
<OverviewText text={result.overview} className="mt-4" /> <OverviewText text={result.overview} className="mt-4" />
</View> </View>
{result.mediaType === MediaType.TV && ( {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) => onAdvancedRequest={(data) =>
advancedReqModalRef?.current?.present(data) setRequestBody(data)
} }
/> />
)} )}
@@ -269,14 +276,17 @@ 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={result.mediaType as MediaType} type={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}

View File

@@ -19,7 +19,7 @@ export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser } = useJellyseerr(); const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const { personId } = local as { personId: string }; const { personId } = local as { personId: string };
@@ -32,15 +32,6 @@ 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(orderBy( uniqBy(orderBy(

View File

@@ -15,7 +15,7 @@ import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo } from "react";
import { View } from "react-native"; import { Platform, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const page: React.FC = () => { const page: React.FC = () => {
@@ -84,22 +84,26 @@ const page: React.FC = () => {
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} type="series" /> <AddToFavorites item={item} />
<DownloadItems {!Platform.isTV && (
size="large" <>
title={t("item_card.download.download_series")} <DownloadItems
items={allEpisodes || []} size="large"
MissingDownloadIconComponent={() => ( title={t("item_card.download.download_series")}
<Ionicons name="download" size={22} color="white" /> items={allEpisodes || []}
)} MissingDownloadIconComponent={() => (
DownloadedIconComponent={() => ( <Ionicons name="download" size={22} color="white" />
<Ionicons )}
name="checkmark-done-outline" DownloadedIconComponent={() => (
size={24} <Ionicons
color="#9333ea" name="checkmark-done-outline"
size={24}
color="#9333ea"
/>
)}
/> />
)} </>
/> )}
</View> </View>
), ),
}); });

View File

@@ -26,12 +26,14 @@ import React, {
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
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"; import { useTranslation } from "react-i18next";
import { eventBus } from "@/utils/eventBus";
type SearchType = "Library" | "Discover"; type SearchType = "Library" | "Discover";
@@ -120,21 +122,44 @@ export default function search() {
[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: true, autoFocus: false,
}, },
}); });
}, [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: () =>
@@ -209,7 +234,12 @@ export default function search() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className="flex flex-col"> <View
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"> <View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<TouchableOpacity onPress={() => setSearchType("Library")}> <TouchableOpacity onPress={() => setSearchType("Library")}>

View File

@@ -10,7 +10,6 @@ import {
} from "@bottom-tabs/react-navigation"; } from "@bottom-tabs/react-navigation";
const { Navigator } = createNativeBottomTabNavigator(); const { Navigator } = createNativeBottomTabNavigator();
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs"; import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
@@ -21,6 +20,7 @@ import type {
TabNavigationState, TabNavigationState,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { eventBus } from "@/utils/eventBus";
export const NativeTabs = withLayoutContext< export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions, BottomTabNavigationOptions,
@@ -55,12 +55,19 @@ export default function TabLayout() {
<NativeTabs <NativeTabs
sidebarAdaptable={false} sidebarAdaptable={false}
ignoresTopSafeArea ignoresTopSafeArea
barTintColor={Platform.OS === "android" ? "#121212" : undefined} tabBarStyle={{
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 }) => ({
tabPress: (e) => {
eventBus.emit("scrollToTop");
},
})}
name="(home)" name="(home)"
options={{ options={{
title: t("tabs.home"), title: t("tabs.home"),
@@ -75,6 +82,11 @@ export default function TabLayout() {
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
listeners={({ navigation }) => ({
tabPress: (e) => {
eventBus.emit("searchTabPressed");
},
})}
name="(search)" name="(search)"
options={{ options={{
title: t("tabs.search"), title: t("tabs.search"),

View File

@@ -3,16 +3,21 @@ import React, { useEffect } from "react";
import { SystemBars } from "react-native-edge-to-edge"; 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 { Platform } from "react-native";
export default function Layout() { export default function Layout() {
const [settings] = useSettings(); const [settings] = useSettings();
useEffect(() => { useEffect(() => {
if (Platform.isTV) return;
if (settings.defaultVideoOrientation) { if (settings.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation); ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
} }
return () => { return () => {
if (Platform.isTV) return;
if (settings.autoRotate === true) { if (settings.autoRotate === true) {
ScreenOrientation.unlockAsync(); ScreenOrientation.unlockAsync();
} else { } else {

View File

@@ -5,46 +5,40 @@ import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener"; import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules/vlc-player"; import { VlcPlayerView } from "@/modules";
import { import {
PipStartedPayload, PipStartedPayload,
PlaybackStatePayload, PlaybackStatePayload,
ProgressUpdatePayload, ProgressUpdatePayload,
VlcPlayerViewRef, VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types"; } from "@/modules/VlcPlayer.types";
// import { useDownload } from "@/providers/DownloadProvider"; const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null;
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
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 { import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
getPlaystateApi, import { getPlaystateApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic"; 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, useMemo, useRef, useState, useEffect } from "react";
useCallback, import { Alert, View, Platform } from "react-native";
useMemo,
useRef,
useState,
useEffect,
} from "react";
import { Alert, View, AppState, AppStateStatus, Platform } from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client"; import {
BaseItemDto,
MediaSourceInfo,
PlaybackOrder,
PlaybackProgressInfo,
PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
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);
@@ -92,152 +86,115 @@ 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 ? parseInt(audioIndexStr, 10) : undefined; const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1; const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
const bitrateValue = bitrateValueStr const bitrateValue = bitrateValueStr ? parseInt(bitrateValueStr, 10) : BITRATES[0].value;
? parseInt(bitrateValueStr, 10)
: BITRATES[0].value;
const { const [item, setItem] = useState<BaseItemDto | null>(null);
data: item, const [itemStatus, setItemStatus] = useState({
isLoading: isLoadingItem, isLoading: true,
isError: isErrorItem, isError: false,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (offline && !Platform.isTV) {
const item = await getDownloadedItem.getDownloadedItem(itemId);
if (item) return item.item;
}
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
enabled: !!itemId,
staleTime: 0,
}); });
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 fetchStream = async () => { const fetchItemData = async () => {
setIsLoadingStream(true); setItemStatus({ isLoading: true, isError: false });
setIsErrorStream(false);
try { try {
let fetchedItem: BaseItemDto | 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) { if (data) fetchedItem = data.item as BaseItemDto;
setStream(null); } else {
return; const res = await getUserLibraryApi(api!).getItem({
} itemId,
userId: user?.Id,
const url = await getDownloadedFileUrl(data.item.Id!); });
fetchedItem = res.data;
if (item) {
setStream({
mediaSource: data.mediaSource as MediaSourceInfo,
url,
sessionId: undefined,
});
return;
}
} }
setItem(fetchedItem);
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("Error fetching stream:", error); console.error("Failed to fetch item:", error);
setIsErrorStream(true); setItemStatus({ isLoading: false, isError: true });
setStream(null);
} finally { } finally {
setIsLoadingStream(false); setItemStatus({ isLoading: false, isError: false });
} }
}; };
fetchStream(); if (itemId) {
}, [itemId, mediaSourceId]); fetchItemData();
}
}, [itemId, offline, api, user?.Id]);
const togglePlay = useCallback(async () => { interface Stream {
if (!api) return; mediaSource: MediaSourceInfo;
sessionId: string;
url: string;
}
const [stream, setStream] = useState<Stream | null>(null);
const [streamStatus, setStreamStatus] = useState({
isLoading: true,
isError: false,
});
useEffect(() => {
const fetchStreamData = async () => {
try {
let result: Stream | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) {
result = { mediaSource: data.mediaSource, sessionId: "", url };
}
} else {
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) return;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
return;
}
result = { mediaSource, sessionId, url };
}
setStream(result);
} catch (error) {
console.error("Failed to fetch stream:", error);
setStreamStatus({ isLoading: false, isError: true });
} finally {
setStreamStatus({ isLoading: false, isError: false });
}
};
fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
const togglePlay = async () => {
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,
@@ -254,12 +211,35 @@ 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 = () => {
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,24 +248,30 @@ export default function page() {
if (offline) return; if (offline) return;
const currentTimeInTicks = msToTicks(currentTime);
if (!item?.Id || !stream) return; if (!item?.Id || !stream) return;
await getPlaystateApi(api!).onPlaybackProgress({ reportPlaybackProgress();
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,
@@ -293,95 +279,40 @@ export default function page() {
offline, offline,
}); });
const onPipStarted = useCallback((e: PipStartedPayload) => { const onPlaybackStateChanged = useCallback(
const { pipStarted } = e.nativeEvent; async (e: PlaybackStatePayload) => {
setIsPipStarted(pipStarted); const { state, isBuffering, isPlaying } = e.nativeEvent;
}, []); if (state === "Playing") {
setIsPlaying(true);
reportPlaybackProgress();
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => { if (state === "Paused") {
const { state, isBuffering, isPlaying } = e.nativeEvent; setIsPlaying(false);
reportPlaybackProgress();
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
if (state === "Playing") { if (isPlaying) {
setIsPlaying(true); setIsPlaying(true);
return; setIsBuffering(false);
} } else if (isBuffering) {
setIsBuffering(true);
if (state === "Paused") { }
setIsPlaying(false); },
return; [reportPlaybackProgress]
}
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 =
stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio"
) || [];
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle"
) || [];
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex
); );
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
const notTranscoding = !stream?.mediaSource.TranscodingUrl; const allAudio = stream?.mediaSource.MediaStreams?.filter((audio) => audio.Type === "Audio") || [];
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
}
if (notTranscoding && chosenAudioTrack) { // Move all the external subtitles last, because vlc places them last.
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`); const allSubs =
} stream?.mediaSource.MediaStreams?.filter((sub) => sub.Type === "Subtitle").sort(
(a, b) => Number(a.IsExternal) - Number(b.IsExternal)
const insets = useSafeAreaInsets(); ) || [];
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
beforeRemoveListener();
};
}, [navigation]);
if (!item || isLoadingItem || !stream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text>
</View>
);
const externalSubtitles = allSubs const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External") .filter((sub: any) => sub.DeliveryMethod === "External")
@@ -390,6 +321,45 @@ export default function page() {
DeliveryUrl: api?.basePath + sub.DeliveryUrl, DeliveryUrl: api?.basePath + sub.DeliveryUrl,
})); }));
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find((sub) => sub.Index === subtitleIndex);
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
if (chosenSubtitleTrack && (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)) {
const finalIndex = notTranscoding ? allSubs.indexOf(chosenSubtitleTrack) : textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
}
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
if (itemStatus.isLoading || streamStatus.isLoading) {
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
}
if (!item || !stream || itemStatus.isError || streamStatus.isError)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text>
</View>
);
return ( return (
<View style={{ flex: 1, backgroundColor: "black" }}> <View style={{ flex: 1, backgroundColor: "black" }}>
<View <View
@@ -419,21 +389,17 @@ export default function page() {
progressUpdateInterval={1000} progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged} onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted} onPipStarted={onPipStarted}
onVideoLoadStart={() => {}}
onVideoLoadEnd={() => { onVideoLoadEnd={() => {
setIsVideoLoaded(true); setIsVideoLoaded(true);
}} }}
onVideoError={(e) => { onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(t("player.error"), t("player.an_error_occured_while_playing_the_video"));
t("player.error"),
t("player.an_error_occured_while_playing_the_video")
);
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}
/> />
</View> </View>
{videoRef.current && !isPipStarted && ( {videoRef.current && !isPipStarted && isMounted === true ? (
<Controls <Controls
mediaSource={stream?.mediaSource} mediaSource={stream?.mediaSource}
item={item} item={item}
@@ -460,10 +426,9 @@ 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,6 +1,5 @@
import "@/augmentations"; import "@/augmentations";
import { Platform } from "react-native"; 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 {
@@ -10,10 +9,6 @@ import {
} 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 {
SplashScreenProvider,
useSplashScreenLoading,
} from "@/providers/SplashScreenProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { Settings, useSettings } from "@/utils/atoms/settings"; import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks"; import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
@@ -32,16 +27,15 @@ 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 { router, Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
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 } from "react"; import { useEffect, useRef } from "react";
import { I18nextProvider, useTranslation } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { Appearance, AppState } 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";
@@ -58,6 +52,15 @@ 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;
@@ -224,17 +227,15 @@ export default function RootLayout() {
Appearance.setColorScheme("dark"); Appearance.setColorScheme("dark");
return ( return (
<SplashScreenProvider> <GestureHandlerRootView style={{ flex: 1 }}>
<GestureHandlerRootView style={{ flex: 1 }}> <JotaiProvider>
<JotaiProvider> <ActionSheetProvider>
<ActionSheetProvider> <I18nextProvider i18n={i18n}>
<I18nextProvider i18n={i18n}> <Layout />
<Layout /> </I18nextProvider>
</I18nextProvider> </ActionSheetProvider>
</ActionSheetProvider> </JotaiProvider>
</JotaiProvider> </GestureHandlerRootView>
</GestureHandlerRootView>
</SplashScreenProvider>
); );
} }
@@ -261,17 +262,15 @@ function Layout() {
}, [settings?.preferedLanguage, i18n]); }, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) { if (!Platform.isTV) {
useKeepAwake();
useNotificationObserver(); useNotificationObserver();
const { i18n } = useTranslation();
useEffect(() => { useEffect(() => {
checkAndRequestPermissions(); checkAndRequestPermissions();
}, []); }, []);
useEffect(() => { useEffect(() => {
// If the user has auto rotate enabled, unlock the orientation // If the user has auto rotate enabled, unlock the orientation
if (Platform.isTV) return;
if (settings.autoRotate === true) { if (settings.autoRotate === true) {
ScreenOrientation.unlockAsync(); ScreenOrientation.unlockAsync();
} else { } else {
@@ -303,16 +302,6 @@ 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>
@@ -324,7 +313,7 @@ function Layout() {
<BottomSheetModalProvider> <BottomSheetModalProvider>
<SystemBars style="light" hidden={false} /> <SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}> <ThemeProvider value={DarkTheme}>
<Stack> <Stack initialRouteName="(auth)/(tabs)">
<Stack.Screen <Stack.Screen
name="(auth)/(tabs)" name="(auth)/(tabs)"
options={{ options={{

View File

@@ -9,7 +9,7 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { 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 { useAtom } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
@@ -19,17 +19,20 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { Keyboard } from "react-native";
import { z } from "zod"; import { z } from "zod";
import { t } from 'i18next'; 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,
@@ -37,6 +40,8 @@ const CredentialsSchema = z.object({
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<{
@@ -47,10 +52,11 @@ const CredentialsSchema = z.object({
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) {
setServer({ setServer({
address: _apiUrl, address: _apiUrl,
@@ -66,7 +72,6 @@ const CredentialsSchema = z.object({
})(); })();
}, [_apiUrl, _username, _password]); }, [_apiUrl, _username, _password]);
const navigation = useNavigation();
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerTitle: serverName, headerTitle: serverName,
@@ -79,15 +84,17 @@ const CredentialsSchema = z.object({
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">{t("login.change_server")}</Text> <Text className="ml-2 text-purple-600">
{t("login.change_server")}
</Text>
</TouchableOpacity> </TouchableOpacity>
) : null, ) : null,
}); });
}, [serverName, navigation, api?.basePath]); }, [serverName, navigation, api?.basePath]);
const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => { const handleLogin = async () => {
Keyboard.dismiss();
setLoading(true); setLoading(true);
try { try {
const result = CredentialsSchema.safeParse(credentials); const result = CredentialsSchema.safeParse(credentials);
@@ -98,15 +105,16 @@ const CredentialsSchema = z.object({
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(t("login.connection_failed"), t("login.an_unexpected_error_occured")); Alert.alert(
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.
* *
@@ -180,14 +188,21 @@ const CredentialsSchema = z.object({
try { try {
const code = await initiateQuickConnect(); const code = await initiateQuickConnect();
if (code) { if (code) {
Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [ Alert.alert(
{ t("login.quick_connect"),
text: t("login.got_it"), t("login.enter_code_to_login", { code: code }),
}, [
]); {
text: t("login.got_it"),
},
]
);
} }
} catch (error) { } catch (error) {
Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect")); Alert.alert(
t("login.error_title"),
t("login.failed_to_initiate_quick_connect")
);
} }
}; };
@@ -201,16 +216,18 @@ const CredentialsSchema = z.object({
<View className="flex flex-col h-full relative items-center justify-center"> <View className="flex flex-col h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full"> <View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2"> <View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold -mb-2"> <Text className="text-2xl font-bold -mb-2">
<> <>
{serverName ? ( {serverName ? (
<> <>
{t("login.login_to_title") + " "} {t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text> <Text className="text-purple-600">{serverName}</Text>
</> </>
) : t("login.login_title")} ) : (
</> t("login.login_title")
</Text> )}
</>
</Text>
<Text className="text-xs text-neutral-400"> <Text className="text-xs text-neutral-400">
{api.basePath} {api.basePath}
</Text> </Text>
@@ -220,7 +237,6 @@ const CredentialsSchema = z.object({
setCredentials({ ...credentials, username: text }) setCredentials({ ...credentials, username: text })
} }
value={credentials.username} value={credentials.username}
autoFocus
secureTextEntry={false} secureTextEntry={false}
keyboardType="default" keyboardType="default"
returnKeyType="done" returnKeyType="done"
@@ -300,7 +316,9 @@ const CredentialsSchema = z.object({
<Button <Button
loading={loadingServerCheck} loading={loadingServerCheck}
disabled={loadingServerCheck} disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)} onPress={async () => {
await handleConnect(serverURL);
}}
className="w-full grow" className="w-full grow"
> >
{t("server.connect_button")} {t("server.connect_button")}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -13,7 +13,6 @@
"@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",
@@ -21,9 +20,6 @@
"@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",
@@ -48,7 +44,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.21", "expo-splash-screen": "~0.29.22",
"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",
@@ -105,8 +101,11 @@
"@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",
"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",
@@ -387,7 +386,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.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/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/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=="],
@@ -401,13 +400,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.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/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/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.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-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-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=="],
@@ -417,7 +416,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.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/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/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=="],
@@ -431,7 +430,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.4", "", {}, "sha512-spXCVXxbeKOe8YZ9igd+MDfXZe6LeDvFAdILijeTSG+XcxGrZLmqMWWkFKR0nV8lTWZ+NugUT3CoiXmEuKKQ7w=="], "@expo/ws-tunnel": ["@expo/ws-tunnel@1.0.5", "", {}, "sha512-Ta9KzslHAIbw2ZoyZ7Ud7/QImucy+K4YvOqo9AhGfUfH76hQzaffQreOySzYusDfW8Y+EXh0ZNWE68dfCumFFw=="],
"@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=="],
@@ -575,8 +574,6 @@
"@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=="],
@@ -679,9 +676,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.0", "", {}, "sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw=="], "@tanstack/query-core": ["@tanstack/query-core@5.66.4", "", {}, "sha512-skM/gzNX4shPkqmdTCSoHtJAPMTtmIJNS0hE+xwTTUVYwezArCT34NMermABmBVUg5Ls5aiUXEDXfqwR1oVkcA=="],
"@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=="], "@tanstack/react-query": ["@tanstack/react-query@5.66.9", "", { "dependencies": { "@tanstack/query-core": "5.66.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-NRI02PHJsP5y2gAuWKP+awamTIBFBSKMnO6UVzi03GTclmHHHInH5UzVgzi5tpu4+FmGfsdT7Umqegobtsp23A=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
@@ -831,7 +828,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.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-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-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=="],
@@ -899,7 +896,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.30001699", "", {}, "sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w=="], "caniuse-lite": ["caniuse-lite@1.0.30001700", "", {}, "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ=="],
"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=="],
@@ -1059,7 +1056,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.100", "", {}, "sha512-u1z9VuzDXV86X2r3vAns0/5ojfXBue9o0+JDUDBKYqGLjxLkSqsSUoPU/6kW0gx76V44frHaf6Zo+QF74TQCMg=="], "electron-to-chromium": ["electron-to-chromium@1.5.103", "", {}, "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -1089,6 +1086,8 @@
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@@ -1113,11 +1112,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.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": ["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-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.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-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-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=="],
@@ -1127,7 +1126,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.6", "", { "dependencies": { "@expo/config": "~10.0.9", "@expo/env": "~0.4.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-rl3/hBIIkh4XDkCEMzGpmY6kWj2G1TA4Mq2joeyzoFBepJuGjqnGl7phf/71sTTgamQ1hmhKCLRNXMpRqzzqxw=="], "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-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=="],
@@ -1143,17 +1142,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.10", "", { "dependencies": { "web-streams-polyfill": "^3.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-+GnxkI+J9tOzUQMx+uIOLBEBsO2meyoYHxd87m9oT9M//BpepYqI1AvYBH8YM4dgr9HaeaeLr7z5XFVqfL8tWg=="], "expo-file-system": ["expo-file-system@18.0.11", "", { "dependencies": { "web-streams-polyfill": "^3.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-yDwYfEzWgPXsBZHJW2RJ8Q66ceiFN9Wa5D20pp3fjXVkzPBDwxnYwiPWk4pVmCa5g4X5KYMoMne1pUrsL4OEpg=="],
"expo-font": ["expo-font@13.0.3", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-9IdYz+A+b3KvuCYP7DUUXF4VMZjPU+IsvAnLSVJ2TfP6zUD2JjZFx3jeo/cxWRkYk/aLj5+53Te7elTAScNl4Q=="], "expo-font": ["expo-font@13.0.4", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-eAP5hyBgC8gafFtprsz0HMaB795qZfgJWqTmU0NfbSin1wUuVySFMEPMOrTkTgmazU73v4Cb4x7p86jY1XXYUw=="],
"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.5", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-FAq7uyaTAfLWER3lN+KVAtep7IfGPZN9ygnVKW4GvgnvR4hKhTtZ5WNxiJ18KKLVb4nUKuHOpQeJNnljy3dtmA=="], "expo-image": ["expo-image@2.0.6", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-NHpIZmGnrPbyDadil6eK+sUgyFMQfapEVb7YaGgxSFWBUQ1rSpjqdIQrCD24IZTO9uSH8V+hMh2ROxrAjAixzQ=="],
"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.2", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-71XAMnoWjKZrN8J7Q3+u0l9Ytp4OfhNAYz8BCWF1/9aFUw09J3I7Z5DuI3MUsVMa/KWi+XhG+eDUFP8cVA19Uw=="], "expo-keep-awake": ["expo-keep-awake@14.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-6Jh94G6NvTZfuLnm2vwIpKe3GdOiVBuISl7FI8GqN0/9UOg9E0WXXp5cDcfAG8bn80RfgLJS8P7EPUGTZyOvhg=="],
"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=="],
@@ -1163,7 +1162,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.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-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-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=="],
@@ -1187,7 +1186,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.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": ["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-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=="],
@@ -1209,7 +1208,7 @@
"fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="], "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=="],
@@ -1241,7 +1240,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.1", "", {}, "sha512-2l5bBKeVtT+d+1CYSsTLJ+iP2FuoR7zjbDQI/v6dDRiBpx3Lb20Z/tLS37ReX/lcodyGSHC2eA/Nk63hB+mkYg=="], "flow-parser": ["flow-parser@0.261.2", "", {}, "sha512-RtunoakA3YjtpAxPSOBVW6lmP5NYmETwkpAfNkdr8Ovf86ENkbD3mtPWnswFTIUtRvjwv0i8ZSkHK+AzsUg1JA=="],
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
@@ -1251,7 +1250,7 @@
"foreground-child": ["foreground-child@3.3.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg=="], "foreground-child": ["foreground-child@3.3.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg=="],
"form-data": ["form-data@4.0.1", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw=="], "form-data": ["form-data@4.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w=="],
"freeport-async": ["freeport-async@2.0.0", "", {}, "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ=="], "freeport-async": ["freeport-async@2.0.0", "", {}, "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ=="],
@@ -1395,8 +1394,6 @@
"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=="],
@@ -1451,7 +1448,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.0", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-j5B4NmUw8gbuN7AG4NufWw00rfpm6hexL2CVhKD7juoP2YyD9FEUV5ar921JMvadyrxQhU1NpuKUL3QfsAlVpA=="], "jotai": ["jotai@2.12.1", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-VUW0nMPYIru5g89tdxwr9ftiVdc/nGV9jvHISN8Ucx+m1vI9dBeHemfqYzEuw5XSkmYjD/MEyApN9k6yrATsZQ=="],
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
@@ -1551,8 +1548,6 @@
"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=="],
@@ -1755,7 +1750,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.2", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA=="], "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-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=="],
@@ -1825,7 +1820,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.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-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-is": ["react-is@19.0.0", "", {}, "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g=="], "react-is": ["react-is@19.0.0", "", {}, "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g=="],
@@ -1837,7 +1832,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.3", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-i51DfTwfLcKorWbTXtnPOcQC4SQDuC+DqKkSl9wF9qAUmNS9PtipYZCXOvWShYFnX0mmcWw5vwEp2b2V73PaDQ=="], "react-native-compressor": ["react-native-compressor@1.10.4", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-58gbmJ+8IvsKP8JKK1E8XW5trfQY3dNuH7S0hYw0tSRQc6l0GZ3k8TYtoUbySOc1xcQSrUo51o0Chwe8x7mUTg=="],
"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=="],
@@ -1911,7 +1906,7 @@
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.3", "", { "dependencies": { "process": "^0.11.10", "readable-stream": "^4.7.0" } }, "sha512-In3boYjBnbGVrLuuRu/Ath/H6h1jgk30nAsk/71tCare1dTVoe1oMBGRn5LGf0n3c1BcHwwAqpraxX4AUAP5KA=="], "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
@@ -2049,7 +2044,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.10", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg=="], "stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="],
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
@@ -2075,7 +2070,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.0.5", "", {}, "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA=="], "strnum": ["strnum@1.1.1", "", {}, "sha512-O7aCHfYCamLCctjAiaucmE+fHf2DYHkus2OKCn4Wv03sykfFtgeECn505X6K4mPl8CRNd/qurC9guq+ynoN4pw=="],
"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=="],
@@ -2199,7 +2194,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.0.5", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA=="], "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"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=="],
@@ -2293,7 +2288,7 @@
"@expo/cli/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "@expo/cli/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"@expo/cli/form-data": ["form-data@3.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-sJe+TQb2vIaIyO783qN6BlMYWMw3WBOHA1Ay2qxsnjuafEOQFJ2JakedOQirT6D5XPRxDvS7AHYyem9fTpb4LQ=="], "@expo/cli/form-data": ["form-data@3.0.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.35" } }, "sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w=="],
"@expo/cli/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "@expo/cli/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
@@ -2301,7 +2296,7 @@
"@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/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=="],

View File

@@ -1,99 +1,15 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { useFavorite } from "@/hooks/useFavorite";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { View } from "react-native";
import { useAtom } from "jotai"; import { RoundButton } from "@/components/RoundButton";
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: React.FC<Props> = ({ item, type, ...props }) => { export const AddToFavorites = ({ item, ...props }) => {
const queryClient = useQueryClient(); const { isFavorite, toggleFavorite, _} = useFavorite(item);
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}>
@@ -101,13 +17,7 @@ export const AddToFavorites: React.FC<Props> = ({ item, type, ...props }) => {
size="large" size="large"
icon={isFavorite ? "heart" : "heart-outline"} icon={isFavorite ? "heart" : "heart-outline"}
fillColor={isFavorite ? "primary" : undefined} fillColor={isFavorite ? "primary" : undefined}
onPress={() => { onPress={toggleFavorite}
if (isFavorite) {
unmarkFavoriteMutation.mutate();
} else {
markFavoriteMutation.mutate();
}
}}
/> />
</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";
@@ -21,7 +21,7 @@ import {
import { Href, router, useFocusEffect } from "expo-router"; import { Href, router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react"; import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, View, ViewProps } from "react-native"; import { Alert, Platform, View, ViewProps } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector"; import { Bitrate, BitrateSelector } from "./BitrateSelector";
@@ -66,10 +66,12 @@ 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>(settings?.defaultBitrate ?? { const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
key: "Max", settings?.defaultBitrate ?? {
value: undefined, key: "Max",
}); value: undefined,
}
);
const userCanDownload = useMemo( const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading, () => user?.Policy?.EnableContentDownloading,
@@ -162,7 +164,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
); );
} }
} else { } else {
toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files")); toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files")
);
} }
}, [ }, [
queue, queue,
@@ -333,7 +337,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
{title} {title}
</Text> </Text>
<Text className="text-neutral-300"> <Text className="text-neutral-300">
{subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})} {subtitle ||
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">
@@ -391,12 +398,16 @@ 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={item.Type == "Episode" title={
? t("item_card.download.download_episode") item.Type == "Episode"
: t("item_card.download.download_movie")} ? t("item_card.download.download_episode")
: t("item_card.download.download_movie")
}
subtitle={item.Name!} subtitle={item.Name!}
items={[item]} items={[item]}
MissingDownloadIconComponent={() => ( MissingDownloadIconComponent={() => (

View File

@@ -21,14 +21,19 @@ export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], t
); );
}; };
export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-xs", ...props }) => { export const Tags: React.FC<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 className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}> <View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
{tags.map((tag, idx) => ( {tags.map((tag, idx) => (
<View key={idx}> <View key={idx}>
<Tag key={idx} textClass={textClass} text={tag}/> <Tag key={idx} textClass={textClass} text={tag} {...tagProps}/>
</View> </View>
))} ))}
</View> </View>

View File

@@ -15,6 +15,7 @@ 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";
@@ -24,17 +25,16 @@ import {
} 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";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null; import { AddToFavorites } from "./AddToFavorites";
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";
import { AddToFavorites } from "./AddToFavorites"; const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
export type SelectedOptions = { export type SelectedOptions = {
bitrate: Bitrate; bitrate: Bitrate;
@@ -94,9 +94,11 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
/> />
{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">
<DownloadSingleItem item={item} size="large" /> {!Platform.isTV && (
<DownloadSingleItem item={item} size="large" />
)}
<PlayedStatus items={[item]} size="large" /> <PlayedStatus items={[item]} size="large" />
<AddToFavorites item={item} type="item" /> <AddToFavorites item={item} />
</View> </View>
)} )}
</View> </View>
@@ -164,7 +166,6 @@ 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">
{/* {!Platform.isTV && ( */}
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink"> <View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<ItemHeader item={item} className="mb-4" /> <ItemHeader item={item} className="mb-4" />
{item.Type !== "Program" && !Platform.isTV && ( {item.Type !== "Program" && !Platform.isTV && (
@@ -222,13 +223,11 @@ 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" && (

View File

@@ -16,6 +16,7 @@ import {
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { Button } from "./Button"; import { Button } from "./Button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatBitrate } from "@/utils/bitrate";
interface Props { interface Props {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -54,14 +55,18 @@ 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">{t("item_card.video")}</Text> <Text className="text-lg font-bold mb-4">
{t("item_card.video")}
</Text>
<View className="flex flex-row space-x-2"> <View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} /> <VideoStreamInfo source={source} />
</View> </View>
</View> </View>
<View className=""> <View className="">
<Text className="text-lg font-bold mb-2">{t("item_card.audio")}</Text> <Text className="text-lg font-bold mb-2">
{t("item_card.audio")}
</Text>
<AudioStreamInfo <AudioStreamInfo
audioStreams={ audioStreams={
source?.MediaStreams?.filter( source?.MediaStreams?.filter(
@@ -72,7 +77,9 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
</View> </View>
<View className=""> <View className="">
<Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text> <Text className="text-lg font-bold mb-2">
{t("item_card.subtitles")}
</Text>
<SubtitleStreamInfo <SubtitleStreamInfo
subtitleStreams={ subtitleStreams={
source?.MediaStreams?.filter( source?.MediaStreams?.filter(
@@ -229,12 +236,3 @@ const formatFileSize = (bytes?: number | null) => {
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString()); const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i]; return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
}; };
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,4 +1,4 @@
import { Platform } from "react-native"; import { Platform, Pressable } 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";
@@ -32,9 +32,8 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Button } from "./Button"; import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent"; import { SelectedOptions } from "./ItemContent";
const chromecastProfile = !Platform.isTV import { chromecast } from "@/utils/profiles/chromecast";
? require("@/utils/profiles/chromecast") import { chromecasth265 } from "@/utils/profiles/chromecasth265";
: null;
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
@@ -72,13 +71,14 @@ export const PlayButton: React.FC<Props> = ({
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => { (q: string) => {
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, selectedOptions.bitrate?.value); goToPlayer(queryString);
return; return;
} }
@@ -113,16 +113,19 @@ export const PlayButton: React.FC<Props> = ({
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
if (!Platform.isTV) { await CastContext.getPlayServicesState().then(async (state) => {
await CastContext.getPlayServicesState().then(async (state) => { if (state && state !== PlayServicesState.SUCCESS) {
if (state && state !== PlayServicesState.SUCCESS) CastContext.showPlayServicesErrorDialog(state);
CastContext.showPlayServicesErrorDialog(state); } else {
else { // Check if user wants H265 for Chromecast
// Get a new URL with the Chromecast device profile: const enableH265 = settings.enableH265ForChromecast;
// Get a new URL with the Chromecast device profile
try {
const data = await getStreamUrl({ const data = await getStreamUrl({
api, api,
item, item,
deviceProfile: chromecastProfile, deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!, startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id, userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex, audioStreamIndex: selectedOptions.audioIndex,
@@ -131,6 +134,8 @@ 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(
@@ -205,12 +210,14 @@ export const PlayButton: React.FC<Props> = ({
} }
CastContext.showExpandedControls(); CastContext.showExpandedControls();
}); });
} catch (e) {
console.log(e);
} }
}); }
} });
break; break;
case 1: case 1:
goToPlayer(queryString, selectedOptions.bitrate?.value); goToPlayer(queryString);
break; break;
case cancelButtonIndex: case cancelButtonIndex:
break; break;
@@ -319,75 +326,62 @@ export const PlayButton: React.FC<Props> = ({
*/ */
return ( return (
<View> <TouchableOpacity
<TouchableOpacity disabled={!item}
disabled={!item} accessibilityLabel="Play button"
accessibilityLabel="Play button" accessibilityHint="Tap to play the media"
accessibilityHint="Tap to play the media" onPress={onPress}
onPress={onPress} className={`relative`}
className={`relative`} {...props}
{...props} >
> <View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
<Animated.View <Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]} style={[
className="absolute w-full h-full top-0 left-0 rounded-xl" animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/> />
<View </View>
style={{
borderWidth: 1, <Animated.View
borderColor: colorAtom.primary, style={[animatedAverageStyle, { opacity: 0.5 }]}
borderStyle: "solid", className="absolute w-full h-full top-0 left-0 rounded-xl"
}} />
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full " <View
> style={{
<View className="flex flex-row items-center space-x-2"> borderWidth: 1,
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}> borderColor: colorAtom.primary,
{runtimeTicksToMinutes(item?.RunTimeTicks)} borderStyle: "solid",
</Animated.Text> }}
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}> <Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} /> <Feather name="cast" size={22} />
<CastButton tintColor="transparent" />
</Animated.Text> </Animated.Text>
{client && ( )}
<Animated.Text style={animatedTextStyle}> {!client && settings?.openInVLC && (
<Feather name="cast" size={22} /> <Animated.Text style={animatedTextStyle}>
<CastButton tintColor="transparent" /> <MaterialCommunityIcons
</Animated.Text> name="vlc"
)} size={18}
{!client && settings?.openInVLC && ( color={animatedTextStyle.color}
<Animated.Text style={animatedTextStyle}> />
<MaterialCommunityIcons </Animated.Text>
name="vlc" )}
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View> </View>
</TouchableOpacity> </View>
{/* <View className="mt-2 flex flex-row items-center"> </TouchableOpacity>
<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

@@ -34,10 +34,10 @@ const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15; const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ export const PlayButton: React.FC<Props> = ({
item, item,
selectedOptions, selectedOptions,
...props ...props
}: Props) => { }: Props) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -57,13 +57,14 @@ export const PlayButton: React.FC<Props> = ({
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => { (q: string) => {
router.push(`/player/direct-player?${q}`); router.push(`/player/direct-player?${q}`);
}, },
[router] [router]
); );
const onPress = useCallback(async () => { const onPress = () => {
console.log("onpress");
if (!item) return; if (!item) return;
lightHapticFeedback(); lightHapticFeedback();
@@ -77,17 +78,9 @@ export const PlayButton: React.FC<Props> = ({
}); });
const queryString = queryParams.toString(); const queryString = queryParams.toString();
goToPlayer(queryString, selectedOptions.bitrate?.value); goToPlayer(queryString);
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;
@@ -95,9 +88,9 @@ export const PlayButton: React.FC<Props> = ({
if (userData && userData.PlaybackPositionTicks) { if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0 return userData.PlaybackPositionTicks > 0
? Math.max( ? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100, (userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH MIN_PLAYBACK_WIDTH
) )
: 0; : 0;
} }
return 0; return 0;
@@ -179,69 +172,55 @@ export const PlayButton: React.FC<Props> = ({
*/ */
return ( return (
<View> <TouchableOpacity
<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">
>
<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 <Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]} style={[
className="absolute w-full h-full top-0 left-0 rounded-xl" animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/> />
<View </View>
style={{
borderWidth: 1, <Animated.View
borderColor: colorAtom.primary, style={[animatedAverageStyle, { opacity: 0.5 }]}
borderStyle: "solid", className="absolute w-full h-full top-0 left-0 rounded-xl"
}} />
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full " <View
> style={{
<View className="flex flex-row items-center space-x-2"> borderWidth: 1,
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}> borderColor: colorAtom.primary,
{runtimeTicksToMinutes(item?.RunTimeTicks)} borderStyle: "solid",
</Animated.Text> }}
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}> <Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} /> <MaterialCommunityIcons
name="vlc"
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text> </Animated.Text>
{settings?.openInVLC && ( )}
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name="vlc"
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View> </View>
</TouchableOpacity> </View>
{/* <View className="mt-2 flex flex-row items-center"> </TouchableOpacity>
<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

@@ -7,6 +7,9 @@ import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
import {useMemo} from "react";
interface Props extends ViewProps { interface Props extends ViewProps {
item?: BaseItemDto | null; item?: BaseItemDto | null;
@@ -49,14 +52,17 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
); );
}; };
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({ export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult | TvDetails | MovieDetails }> = ({
result, result,
}) => { }) => {
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi, getMediaType } = useJellyseerr();
const mediaType = useMemo(() => getMediaType(result), [result]);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["jellyseerr", result.id, result.mediaType, "ratings"], queryKey: ["jellyseerr", result.id, mediaType, "ratings"],
queryFn: async () => { queryFn: async () => {
return result.mediaType === MediaType.MOVIE return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieRatings(result.id) ? jellyseerrApi?.movieRatings(result.id)
: jellyseerrApi?.tvRatings(result.id); : jellyseerrApi?.tvRatings(result.id);
}, },

View File

@@ -43,7 +43,7 @@ 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 className="opacity-50 mb-1 text-xs"> <Text numberOfLines={1} className="opacity-50 mb-1 text-xs">
{t("item_card.subtitles")} {t("item_card.subtitles")}
</Text> </Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">

View File

@@ -18,7 +18,7 @@ interface Props<T> {
title: string | ReactNode; title: string | ReactNode;
label: string; label: string;
onSelected: (...item: T[]) => void; onSelected: (...item: T[]) => void;
multi?: boolean; multiple?: boolean;
} }
const Dropdown = <T extends unknown>({ const Dropdown = <T extends unknown>({
@@ -30,7 +30,7 @@ const Dropdown = <T extends unknown>({
title, title,
label, label,
onSelected, onSelected,
multi = false, multiple = false,
...props ...props
}: PropsWithChildren<Props<T> & ViewProps>) => { }: PropsWithChildren<Props<T> & ViewProps>) => {
if (Platform.isTV) return null; if (Platform.isTV) return null;
@@ -72,7 +72,7 @@ const Dropdown = <T extends unknown>({
> >
<DropdownMenu.Label>{label}</DropdownMenu.Label> <DropdownMenu.Label>{label}</DropdownMenu.Label>
{data.map((item, idx) => {data.map((item, idx) =>
multi ? ( multiple ? (
<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 extends unknown>({
: "off" : "off"
} }
key={keyExtractor(item)} key={keyExtractor(item)}
onValueChange={(next, previous) => onValueChange={(next: "on" | "off", previous: "on" | "off") => {
setSelected((p) => { setSelected((p) => {
const prev = p || []; const prev = p || [];
if (next == "on") { if (next == "on") {
@@ -92,7 +92,7 @@ const Dropdown = <T extends unknown>({
), ),
]; ];
}) })
} }}
> >
<DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
{titleExtractor(item)} {titleExtractor(item)}

View File

@@ -9,13 +9,16 @@ import {
Permission, Permission,
} from "@/utils/jellyseerr/server/lib/permissions"; } from "@/utils/jellyseerr/server/lib/permissions";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
result: MovieResult | TvResult; result: MovieResult | TvResult | MovieDetails | TvDetails;
mediaTitle: string; mediaTitle: string;
releaseYear: number; releaseYear: number;
canRequest: boolean; canRequest: boolean;
posterSrc: string; posterSrc: string;
mediaType: MediaType;
} }
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
@@ -24,6 +27,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
releaseYear, releaseYear,
canRequest, canRequest,
posterSrc, posterSrc,
mediaType,
children, children,
...props ...props
}) => { }) => {
@@ -46,7 +50,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
() => () =>
requestMedia(mediaTitle, { requestMedia(mediaTitle, {
mediaId: result.id, mediaId: result.id,
mediaType: result.mediaType, mediaType,
}), }),
[jellyseerrApi, result] [jellyseerrApi, result]
); );
@@ -67,6 +71,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
releaseYear, releaseYear,
canRequest, canRequest,
posterSrc, posterSrc,
mediaType
}, },
}); });
}} }}
@@ -83,7 +88,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
key={"content"} key={"content"}
> >
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label> <ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
{canRequest && result.mediaType === MediaType.MOVIE && ( {canRequest && mediaType === MediaType.MOVIE && (
<ContextMenu.Item <ContextMenu.Item
key="item-1" key="item-1"
onSelect={() => { onSelect={() => {

View File

@@ -1,19 +1,27 @@
import React from "react"; import React from "react";
import { TextProps } from "react-native"; import { Platform, TextProps } from "react-native";
import { UITextView } from "react-native-uitextview"; import { UITextView } from "react-native-uitextview";
import { Text as RNText } from "react-native";
export function Text( export function Text(
props: TextProps & { props: TextProps & {
uiTextView?: boolean; uiTextView?: boolean;
} }
) { ) {
const { style, ...otherProps } = props; const { style, ...otherProps } = props;
if (Platform.isTV)
return ( return (
<UITextView <RNText
allowFontScaling={false} allowFontScaling={false}
style={[{ color: "white" }, style]} style={[{ color: "white" }, style]}
{...otherProps} {...otherProps}
/> />
); );
else
return (
<UITextView
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
/>
);
} }

View File

@@ -1,4 +1,5 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useFavorite } from "@/hooks/useFavorite";
import { import {
BaseItemDto, BaseItemDto,
BaseItemPerson, BaseItemPerson,
@@ -7,7 +8,6 @@ import { useRouter, useSegments } from "expo-router";
import { PropsWithChildren, useCallback } from "react"; import { PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native"; import { TouchableOpacity, TouchableOpacityProps } from "react-native";
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
@@ -57,14 +57,14 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const segments = useSegments(); const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]); const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item);
const from = segments[2]; const from = segments[2];
const showActionSheet = useCallback(() => { const showActionSheet = useCallback(() => {
if (!(item.Type === "Movie" || item.Type === "Episode")) return; if (!(item.Type === "Movie" || item.Type === "Episode" || item.Type === "Series")) return;
const options = ["Mark as Played", "Mark as Not Played", isFavorite ? "Unmark as Favorite" : "Mark as Favorite", "Cancel"];
const options = ["Mark as Played", "Mark as Not Played", "Cancel"]; const cancelButtonIndex = 3;
const cancelButtonIndex = 2;
showActionSheetWithOptions( showActionSheetWithOptions(
{ {
@@ -74,14 +74,14 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
async (selectedIndex) => { async (selectedIndex) => {
if (selectedIndex === 0) { if (selectedIndex === 0) {
await markAsPlayedStatus(true); await markAsPlayedStatus(true);
// Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else if (selectedIndex === 1) { } else if (selectedIndex === 1) {
await markAsPlayedStatus(false); await markAsPlayedStatus(false);
// Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } else if (selectedIndex === 2) {
toggleFavorite()
} }
} }
); );
}, [showActionSheetWithOptions, markAsPlayedStatus]); }, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);
if ( if (
from === "(home)" || from === "(home)" ||

View File

@@ -1,16 +1,15 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
import { JobStatus } from "@/utils/optimize-server"; import { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time"; import { formatTimeString } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
const FFmpegKitProvider = !Platform.isTV ? require("ffmpeg-kit-react-native") : null; import { t } from "i18next";
import { useAtom } from "jotai"; import { useMemo } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Platform, Platform,
@@ -21,10 +20,12 @@ import {
} from "react-native"; } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Button } from "../Button"; import { Button } from "../Button";
import { Image } from "expo-image"; const BackGroundDownloader = !Platform.isTV
import { useMemo } from "react"; ? require("@kesha-antonov/react-native-background-downloader")
import { storage } from "@/utils/mmkv"; : null;
import { t } from "i18next"; const FFmpegKitProvider = !Platform.isTV
? require("ffmpeg-kit-react-native")
: null;
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -33,14 +34,20 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
if (processes?.length === 0) if (processes?.length === 0)
return ( return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl"> <View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">{t("home.downloads.active_download")}</Text> <Text className="text-lg font-bold">
<Text className="opacity-50">{t("home.downloads.no_active_downloads")}</Text> {t("home.downloads.active_download")}
</Text>
<Text className="opacity-50">
{t("home.downloads.no_active_downloads")}
</Text>
</View> </View>
); );
return ( return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl"> <View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold mb-2">{t("home.downloads.active_downloads")}</Text> <Text className="text-lg font-bold mb-2">
{t("home.downloads.active_downloads")}
</Text>
<View className="space-y-2"> <View className="space-y-2">
{processes?.map((p: JobStatus) => ( {processes?.map((p: JobStatus) => (
<DownloadCard key={p.item.Id} process={p} /> <DownloadCard key={p.item.Id} process={p} />
@@ -81,7 +88,9 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
} }
} else { } else {
FFmpegKitProvider.FFmpegKit.cancel(Number(id)); FFmpegKitProvider.FFmpegKit.cancel(Number(id));
setProcesses((prev: any[]) => prev.filter((p: { id: string; }) => p.id !== id)); setProcesses((prev: any[]) =>
prev.filter((p: { id: string }) => p.id !== id)
);
} }
}, },
onSuccess: () => { onSuccess: () => {
@@ -156,7 +165,9 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text> <Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
)} )}
{eta(process) && ( {eta(process) && (
<Text className="text-xs">{t("home.downloads.eta", {eta: eta(process)})}</Text> <Text className="text-xs">
{t("home.downloads.eta", { eta: eta(process) })}
</Text>
)} )}
</View> </View>

View File

@@ -19,7 +19,7 @@ interface Release {
type: number; type: number;
} }
const dateOpts: Intl.DateTimeFormatOptions = { export const dateOpts: Intl.DateTimeFormatOptions = {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
@@ -50,18 +50,9 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
const DetailFacts: React.FC< const DetailFacts: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps { details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, className, ...props }) => { > = ({ details, className, ...props }) => {
const { jellyseerrUser } = useJellyseerr(); const { jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const { t } = useTranslation(); const { t } = useTranslation();
const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
const region = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const releases = useMemo( const releases = useMemo(
() => () =>
(details as MovieDetails)?.releases?.results.find( (details as MovieDetails)?.releases?.results.find(

View File

@@ -21,6 +21,7 @@ import { LoadingSkeleton } from "../search/LoadingSkeleton";
import { SearchItemWrapper } from "../search/SearchItemWrapper"; import { SearchItemWrapper } from "../search/SearchItemWrapper";
import PersonPoster from "./PersonPoster"; import PersonPoster from "./PersonPoster";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {uniqBy} from "lodash";
interface Props extends ViewProps { interface Props extends ViewProps {
searchQuery: string; searchQuery: string;
@@ -77,25 +78,28 @@ export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => {
const jellyseerrMovieResults = useMemo( const jellyseerrMovieResults = useMemo(
() => () =>
jellyseerrResults?.filter( uniqBy(
(r) => r.mediaType === MediaType.MOVIE jellyseerrResults?.filter((r) => r.mediaType === MediaType.MOVIE) as MovieResult[],
) as MovieResult[], "id"
),
[jellyseerrResults] [jellyseerrResults]
); );
const jellyseerrTvResults = useMemo( const jellyseerrTvResults = useMemo(
() => () =>
jellyseerrResults?.filter( uniqBy(
(r) => r.mediaType === MediaType.TV jellyseerrResults?.filter((r) => r.mediaType === MediaType.TV) as TvResult[],
) as TvResult[], "id"
),
[jellyseerrResults] [jellyseerrResults]
); );
const jellyseerrPersonResults = useMemo( const jellyseerrPersonResults = useMemo(
() => () =>
jellyseerrResults?.filter( uniqBy(
(r) => r.mediaType === "person" jellyseerrResults?.filter((r) => r.mediaType === "person") as PersonResult[],
) as PersonResult[], "id"
),
[jellyseerrResults] [jellyseerrResults]
); );

View File

@@ -15,18 +15,22 @@ import { useTranslation } from "react-i18next";
interface Props { interface Props {
id: number; id: number;
title: string, title: string,
requestBody?: MediaRequestBody,
type: MediaType; type: MediaType;
isAnime?: boolean; isAnime?: boolean;
is4k?: boolean; is4k?: boolean;
onRequested?: () => void; onRequested?: () => void;
onDismiss?: () => void;
} }
const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps, 'id'>>(({ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps, 'id'>>(({
id, id,
title, title,
requestBody,
type, type,
isAnime = false, isAnime = false,
onRequested, onRequested,
onDismiss,
...props ...props
}, ref) => { }, ref) => {
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr(); const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr();
@@ -39,8 +43,6 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
const { t } = useTranslation(); const { t } = useTranslation();
const [modalRequestProps, setModalRequestProps] = useState<MediaRequestBody>();
const {data: serviceSettings} = useQuery({ const {data: serviceSettings} = useQuery({
queryKey: ["jellyseerr", "request", type, 'service'], queryKey: ["jellyseerr", "request", type, 'service'],
queryFn: async () => jellyseerrApi?.service(type == 'movie' ? 'radarr' : 'sonarr'), queryFn: async () => jellyseerrApi?.service(type == 'movie' ? 'radarr' : 'sonarr'),
@@ -98,16 +100,19 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
: defaultServiceDetails?.server.activeTags : defaultServiceDetails?.server.activeTags
)?.includes(t.id) )?.includes(t.id)
) ?? [] ) ?? []
console.log(tags)
return tags return tags
}, },
[defaultServiceDetails] [defaultServiceDetails]
); );
const seasonTitle = useMemo( const seasonTitle = useMemo(
() => modalRequestProps?.seasons?.length ? t("jellyseerr.season_x", {seasons: modalRequestProps?.seasons}) : undefined, () => {
[modalRequestProps?.seasons] if (requestBody?.seasons && requestBody?.seasons?.length > 1) {
return t("jellyseerr.season_all")
}
return t("jellyseerr.season_number", {season_number: requestBody?.seasons})
},
[requestBody?.seasons]
); );
const request = useCallback(() => {requestMedia( const request = useCallback(() => {requestMedia(
@@ -117,12 +122,12 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
profileId: defaultProfile.id, profileId: defaultProfile.id,
rootFolder: defaultFolder.path, rootFolder: defaultFolder.path,
tags: defaultTags.map(t => t.id), tags: defaultTags.map(t => t.id),
...modalRequestProps, ...requestBody,
...requestOverrides ...requestOverrides
}, },
onRequested onRequested
) )
}, [modalRequestProps, requestOverrides, defaultProfile, defaultFolder, defaultTags]); }, [requestBody, requestOverrides, defaultProfile, defaultFolder, defaultTags]);
const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`; const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`;
@@ -131,7 +136,7 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
ref={ref} ref={ref}
enableDynamicSizing enableDynamicSizing
enableDismissOnClose enableDismissOnClose
onDismiss={() => setModalRequestProps(undefined)} onDismiss={onDismiss}
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
}} }}
@@ -146,89 +151,86 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
/> />
} }
> >
{(data) => { <BottomSheetView>
setModalRequestProps(data?.data as MediaRequestBody) <View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
return <BottomSheetView> <View>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2"> <Text className="font-bold text-2xl text-neutral-100">{t("jellyseerr.advanced")}</Text>
<View> {seasonTitle &&
<Text className="font-bold text-2xl text-neutral-100">{t("jellyseerr.advanced")}</Text> <Text className="text-neutral-300">{seasonTitle}</Text>
{seasonTitle && }
<Text className="text-neutral-300">{seasonTitle}</Text>
}
</View>
<View className="flex flex-col space-y-2">
{(defaultService && defaultServiceDetails && users) && (
<>
<Dropdown
data={defaultServiceDetails.profiles}
titleExtractor={(item) => item.name}
placeholderText={defaultProfile.name}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.quality_profile")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
profileId: item?.id
}))
}
title={t("jellyseerr.quality_profile")}
/>
<Dropdown
data={defaultServiceDetails.rootFolders}
titleExtractor={pathTitleExtractor}
placeholderText={defaultFolder ? pathTitleExtractor(defaultFolder) : ""}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.root_folder")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
rootFolder: item.path
}))}
title={t("jellyseerr.root_folder")}
/>
<Dropdown
multi={true}
data={defaultServiceDetails.tags}
titleExtractor={(item) => item.label}
placeholderText={defaultTags.map(t => t.label).join(",")}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.tags")}
onSelected={(...item) =>
item && setRequestOverrides((prev) => ({
...prev,
tags: item.map(i => i.id)
}))
}
title={t("jellyseerr.tags")}
/>
<Dropdown
data={users}
titleExtractor={(item) => item.displayName}
placeholderText={jellyseerrUser!!.displayName}
keyExtractor={(item) => item.id.toString() || ""}
label={t("jellyseerr.request_as")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
userId: item?.id
}))
}
title={t("jellyseerr.request_as")}
/>
</>
)
}
</View>
<Button
className="mt-auto"
onPress={request}
color="purple"
>
{t("jellyseerr.request_button")}
</Button>
</View> </View>
</BottomSheetView> <View className="flex flex-col space-y-2">
}} {(defaultService && defaultServiceDetails && users) && (
<>
<Dropdown
data={defaultServiceDetails.profiles}
titleExtractor={(item) => item.name}
placeholderText={requestOverrides.profileName || defaultProfile.name}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.quality_profile")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
profileId: item?.id
}))
}
title={t("jellyseerr.quality_profile")}
/>
<Dropdown
data={defaultServiceDetails.rootFolders}
titleExtractor={pathTitleExtractor}
placeholderText={defaultFolder ? pathTitleExtractor(defaultFolder) : ""}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.root_folder")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
rootFolder: item.path
}))}
title={t("jellyseerr.root_folder")}
/>
<Dropdown
multiple
data={defaultServiceDetails.tags}
titleExtractor={(item) => item.label}
placeholderText={defaultTags.map(t => t.label).join(",")}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.tags")}
onSelected={(...selected) =>
setRequestOverrides((prev) => ({
...prev,
tags: selected.map(i => i.id)
}))
}
title={t("jellyseerr.tags")}
/>
<Dropdown
data={users}
titleExtractor={(item) => item.displayName}
placeholderText={jellyseerrUser!!.displayName}
keyExtractor={(item) => item.id.toString() || ""}
label={t("jellyseerr.request_as")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
userId: item?.id
}))
}
title={t("jellyseerr.request_as")}
/>
</>
)
}
</View>
<Button
className="mt-auto"
onPress={request}
color="purple"
>
{t("jellyseerr.request_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal> </BottomSheetModal>
); );
}); });

View File

@@ -8,6 +8,7 @@ import {View} from "react-native";
import {networks} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider"; import {networks} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import {studios} from "@/utils/jellyseerr/src/components/Discover/StudioSlider"; import {studios} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
import GenreSlide from "@/components/jellyseerr/discover/GenreSlide"; import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
import RecentRequestsSlide from "@/components/jellyseerr/discover/RecentRequestsSlide";
interface Props { interface Props {
sliders?: DiscoverSlider[]; sliders?: DiscoverSlider[];
@@ -25,6 +26,8 @@ const Discover: React.FC<Props> = ({ sliders }) => {
<View className="flex flex-col space-y-4 mb-8"> <View className="flex flex-col space-y-4 mb-8">
{sortedSliders.map(slide => { {sortedSliders.map(slide => {
switch (slide.type) { switch (slide.type) {
case DiscoverSliderType.RECENT_REQUESTS:
return <RecentRequestsSlide key={slide.id} slide={slide} />
case DiscoverSliderType.NETWORKS: case DiscoverSliderType.NETWORKS:
return <CompanySlide key={slide.id} slide={slide} data={networks}/> return <CompanySlide key={slide.id} slide={slide} data={networks}/>
case DiscoverSliderType.STUDIOS: case DiscoverSliderType.STUDIOS:

View File

@@ -10,6 +10,7 @@ import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide"; import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
import {ViewProps} from "react-native"; import {ViewProps} from "react-native";
import {uniqBy} from "lodash";
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => { const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
@@ -57,7 +58,11 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) =>
}); });
const flatData = useMemo( const flatData = useMemo(
() => data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results), () =>
uniqBy(
data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
"id"
),
[data] [data]
); );
@@ -74,7 +79,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) =>
fetchNextPage() fetchNextPage()
}} }}
renderItem={(item) => renderItem={(item) =>
<JellyseerrPoster item={item as MovieResult | TvResult} /> <JellyseerrPoster item={item as MovieResult | TvResult} key={item?.id}/>
} }
/> />
) )

View File

@@ -0,0 +1,69 @@
import React from "react";
import {useQuery} from "@tanstack/react-query";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
import {ViewProps} from "react-native";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import {NonFunctionProperties} from "@/utils/jellyseerr/server/interfaces/api/common";
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
const RequestCard: React.FC<{request: MediaRequest}> = ({request}) => {
const {jellyseerrApi} = useJellyseerr();
const { data: details, isLoading, isError } = useQuery({
queryKey: ["jellyseerr", "detail", request.media.mediaType, request.media.tmdbId],
queryFn: async () => {
return request.media.mediaType == MediaType.MOVIE
? jellyseerrApi?.movieDetails(request.media.tmdbId)
: jellyseerrApi?.tvDetails(request.media.tmdbId);
},
enabled: !!jellyseerrApi,
refetchOnMount: true,
staleTime: 0,
});
const { data: refreshedRequest } = useQuery({
queryKey: ["jellyseerr", "requests", request.media.mediaType, request.id],
queryFn: async () => jellyseerrApi?.getRequest(request.id),
enabled: !!jellyseerrApi,
refetchOnMount: true,
refetchInterval: 5000,
staleTime: 0,
});
return (
details && <JellyseerrPoster horizontal showDownloadInfo item={details} mediaRequest={refreshedRequest} />
)
}
const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const {jellyseerrApi} = useJellyseerr();
const { data: requests, isLoading, isError } = useQuery({
queryKey: ["jellyseerr", "recent_requests"],
queryFn: async () => jellyseerrApi?.requests(),
enabled: !!jellyseerrApi,
refetchOnMount: true,
staleTime: 0,
});
return (
requests &&
requests.results.length > 0 &&
!isError && (
<Slide
{...props}
slide={slide}
data={requests.results}
keyExtractor={(item) => item.id.toString()}
renderItem={(item: NonFunctionProperties<MediaRequest>) => (
<RequestCard request={item}/>
)}
/>
)
)
};
export default RecentRequestsSlide;

View File

@@ -29,7 +29,7 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
</Text> </Text>
<View <View
style={[]} style={[]}
className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900" className="flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900"
> >
{Children.map(childrenArray, (child, index) => { {Children.map(childrenArray, (child, index) => {
if (isValidElement<{ style?: ViewStyle }>(child)) { if (isValidElement<{ style?: ViewStyle }>(child)) {

View File

@@ -36,7 +36,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity <TouchableOpacity
disabled={disabled} disabled={disabled}
onPress={onPress} onPress={onPress}
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${ className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
disabled ? "opacity-50" : "" disabled ? "opacity-50" : ""
}`} }`}
{...props} {...props}
@@ -55,7 +55,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
); );
return ( return (
<View <View
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${ className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
disabled ? "opacity-50" : "" disabled ? "opacity-50" : ""
}`} }`}
{...props} {...props}

View File

@@ -1,28 +1,42 @@
import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter"; import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
import { Text } from "@/components/common/Text"; import {Text} from "@/components/common/Text";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon"; import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon"; import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import {useJellyseerr} from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; import {useJellyseerrCanRequest} from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search"; import {Image} from "expo-image";
import { Image } from "expo-image"; import {useMemo} from "react";
import { useMemo } from "react"; import {View, ViewProps} from "react-native";
import { View, ViewProps } from "react-native"; import Animated, {useAnimatedStyle, useSharedValue, withTiming,} from "react-native-reanimated";
import Animated, { import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
useAnimatedStyle, import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
useSharedValue, import type {DownloadingItem} from "@/utils/jellyseerr/server/lib/downloadtracker";
withTiming, import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
} from "react-native-reanimated"; import {useTranslation} from "react-i18next";
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
import {Colors} from "@/constants/Colors";
import {Tags} from "@/components/GenreTags";
interface Props extends ViewProps { interface Props extends ViewProps {
item: MovieResult | TvResult; item: MovieResult | TvResult | MovieDetails | TvDetails;
horizontal?: boolean;
showDownloadInfo?: boolean;
mediaRequest?: MediaRequest;
} }
const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => { const JellyseerrPoster: React.FC<Props> = ({
const { jellyseerrApi } = useJellyseerr(); item,
horizontal,
showDownloadInfo,
mediaRequest,
...props
}) => {
const { jellyseerrApi, getTitle, getYear, getMediaType, isJellyseerrResult } = useJellyseerr();
const loadingOpacity = useSharedValue(1); const loadingOpacity = useSharedValue(1);
const imageOpacity = useSharedValue(0); const imageOpacity = useSharedValue(0);
const {t} = useTranslation();
const loadingAnimatedStyle = useAnimatedStyle(() => ({ const loadingAnimatedStyle = useAnimatedStyle(() => ({
opacity: loadingOpacity.value, opacity: loadingOpacity.value,
@@ -38,27 +52,64 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
}; };
const imageSrc = useMemo( const imageSrc = useMemo(
() => jellyseerrApi?.imageProxy(item.posterPath, "w300_and_h450_face"), () => jellyseerrApi?.imageProxy(
[item, jellyseerrApi] horizontal ? item.backdropPath : item.posterPath,
horizontal ? "w1920_and_h800_multi_faces" : "w300_and_h450_face"
),
[item, jellyseerrApi, horizontal]
); );
const title = useMemo( const title = useMemo(() => getTitle(item), [item]);
() => (item.mediaType === MediaType.MOVIE ? item.title : item.name), const releaseYear = useMemo(() => getYear(item), [item]);
[item] const mediaType = useMemo(() => getMediaType(item), [item]);
);
const releaseYear = useMemo( const size = useMemo(() => horizontal ? 'h-28' : 'w-28', [horizontal])
() => const ratio = useMemo(() => horizontal ? '15/10' : '10/15', [horizontal])
new Date(
item.mediaType === MediaType.MOVIE
? item.releaseDate
: item.firstAirDate
).getFullYear(),
[item]
);
const [canRequest] = useJellyseerrCanRequest(item); const [canRequest] = useJellyseerrCanRequest(item);
const is4k = useMemo(
() => mediaRequest?.is4k === true,
[mediaRequest]
);
const downloadItems = useMemo(
() => (is4k ? mediaRequest?.media.downloadStatus4k : mediaRequest?.media.downloadStatus) || [],
[mediaRequest, is4k]
)
const progress = useMemo(() => {
const [totalSize, sizeLeft] = downloadItems
.reduce((sum: number[], next: DownloadingItem) =>
[sum[0] + next.size, sum[1] + next.sizeLeft],
[0, 0]
);
return (((totalSize - sizeLeft) / totalSize) * 100);
},
[downloadItems]
);
const requestedSeasons: string[] | undefined = useMemo(
() => {
const seasons = mediaRequest?.seasons?.flatMap(s => s.seasonNumber.toString()) || []
if (seasons.length > 4) {
const [first, second, third, fourth, ...rest] = seasons;
return [first, second, third, fourth, t("home.settings.plugins.jellyseerr.plus_n_more", {n: rest.length })]
}
return seasons
},
[mediaRequest]
);
const available = useMemo(
() => {
const status = mediaRequest?.media?.[is4k ? 'status4k' : 'status'];
return status === MediaStatus.AVAILABLE
},
[mediaRequest, is4k]
);
return ( return (
<TouchableJellyseerrRouter <TouchableJellyseerrRouter
result={item} result={item}
@@ -66,9 +117,10 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
releaseYear={releaseYear} releaseYear={releaseYear}
canRequest={canRequest} canRequest={canRequest}
posterSrc={imageSrc!!} posterSrc={imageSrc!!}
mediaType={mediaType}
> >
<View className="flex flex-col w-28 mr-2"> <View className={`flex flex-col mr-2 h-auto`}>
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]"> <View className={`relative rounded-lg overflow-hidden border border-neutral-900 ${size} aspect-[${ratio}]`}>
<Animated.View style={imageAnimatedStyle}> <Animated.View style={imageAnimatedStyle}>
<Image <Image
key={item.id} key={item.id}
@@ -77,26 +129,65 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
cachePolicy={"memory-disk"} cachePolicy={"memory-disk"}
contentFit="cover" contentFit="cover"
style={{ style={{
aspectRatio: "10/15", aspectRatio: ratio,
width: "100%", [horizontal ? 'height' : 'width']: "100%"
}} }}
onLoad={handleImageLoad} onLoad={handleImageLoad}
/> />
</Animated.View> </Animated.View>
{mediaRequest && showDownloadInfo && (
<>
<View className={`absolute w-full h-full bg-black ${!available ? 'opacity-70' : 'opacity-0'}`} />
{!available && !Number.isNaN(progress) && (
<>
<View
className="absolute left-0 h-full opacity-40"
style={{
width: `${progress || 0}%`,
backgroundColor: Colors.primaryRGB,
}}
/>
<View className="absolute w-full h-full justify-center items-center">
<Text
className="font-bold"
style={textShadowStyle.shadow}
>
{progress?.toFixed(0)}%
</Text>
</View>
</>
)}
<Text
className="absolute right-1 top-1 text-right font-bold"
style={textShadowStyle.shadow}
>
{mediaRequest?.requestedBy.displayName}
</Text>
{requestedSeasons.length > 0 && (
<Tags
className="absolute bottom-1 left-0.5 w-32"
tagProps={{
className: "bg-black rounded-full px-1"
}}
tags={requestedSeasons}
/>
)}
</>
)}
<JellyseerrStatusIcon <JellyseerrStatusIcon
className="absolute bottom-1 right-1" className="absolute bottom-1 right-1"
showRequestIcon={canRequest} showRequestIcon={canRequest}
mediaStatus={item?.mediaInfo?.status} mediaStatus={mediaRequest?.media?.status || item?.mediaInfo?.status}
/> />
<JellyseerrMediaIcon <JellyseerrMediaIcon
className="absolute top-1 left-1" className="absolute top-1 left-1"
mediaType={item?.mediaType} mediaType={mediaType}
/> />
</View> </View>
<View className="mt-2 flex flex-col"> </View>
<Text numberOfLines={2}>{title}</Text> <View className={`mt-2 flex flex-col ${horizontal ? 'w-44' : 'w-28'}`}>
<Text className="text-xs opacity-50 align-bottom">{releaseYear}</Text> <Text numberOfLines={2}>{title}</Text>
</View> <Text className="text-xs opacity-50 align-bottom">{releaseYear}</Text>
</View> </View>
</TouchableJellyseerrRouter> </TouchableJellyseerrRouter>
); );

View File

@@ -23,6 +23,8 @@ import { Loader } from "../Loader";
import { t } from "i18next"; import { t } from "i18next";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie"; import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
import {dateOpts} from "@/components/jellyseerr/DetailFacts";
const JellyseerrSeasonEpisodes: React.FC<{ const JellyseerrSeasonEpisodes: React.FC<{
details: TvDetails; details: TvDetails;
@@ -52,26 +54,51 @@ const JellyseerrSeasonEpisodes: React.FC<{
}; };
const RenderItem = ({ item, index }: any) => { const RenderItem = ({ item, index }: any) => {
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const upcomingAirDate = useMemo(() => {
const airDate = item.airDate;
if (airDate) {
let airDateObj = new Date(airDate);
if (new Date() < airDateObj) {
return airDateObj.toLocaleDateString(
`${locale}-${region}`,
dateOpts
);
}
}
}, [item]);
return ( return (
<View className="flex flex-col w-44 mt-2"> <View className="flex flex-col w-44 mt-2">
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"> <View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
{!imageError ? ( {!imageError ? (
<Image <>
key={item.id} <Image
id={item.id} key={item.id}
source={{ id={item.id}
uri: jellyseerrApi?.imageProxy(item.stillPath), source={{
}} uri: jellyseerrApi?.imageProxy(item.stillPath),
cachePolicy={"memory-disk"} }}
contentFit="cover" cachePolicy={"memory-disk"}
className="w-full h-full" contentFit="cover"
onError={(e) => { className="w-full h-full"
setImageError(true); onError={(e) => {
}} setImageError(true);
/> }}
/>
{upcomingAirDate && (
<View className="absolute justify-center bottom-0 right-0.5 items-center">
<View className="rounded-full bg-purple-600/30 p-1">
<Text className="text-center text-xs" style={textShadowStyle.shadow}>
{upcomingAirDate}
</Text>
</View>
</View>
)}
</>
) : ( ) : (
<View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900"> <View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900">
<Ionicons <Ionicons
@@ -101,14 +128,12 @@ const RenderItem = ({ item, index }: any) => {
const JellyseerrSeasons: React.FC<{ const JellyseerrSeasons: React.FC<{
isLoading: boolean; isLoading: boolean;
result?: TvResult;
details?: TvDetails; details?: TvDetails;
hasAdvancedRequest?: boolean, hasAdvancedRequest?: boolean,
onAdvancedRequest?: (data: MediaRequestBody) => void; onAdvancedRequest?: (data: MediaRequestBody) => void;
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>; refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
}> = ({ }> = ({
isLoading, isLoading,
result,
details, details,
refetch, refetch,
hasAdvancedRequest, hasAdvancedRequest,
@@ -168,7 +193,7 @@ const JellyseerrSeasons: React.FC<{
return onAdvancedRequest?.(body) return onAdvancedRequest?.(body)
} }
requestMedia(result?.name!!, body, refetch); requestMedia(details.name, body, refetch);
} }
}, [jellyseerrApi, seasons, details, hasAdvancedRequest, onAdvancedRequest]); }, [jellyseerrApi, seasons, details, hasAdvancedRequest, onAdvancedRequest]);
@@ -200,7 +225,7 @@ const JellyseerrSeasons: React.FC<{
return onAdvancedRequest?.(body) return onAdvancedRequest?.(body)
} }
requestMedia(`${result?.name!!}, Season ${seasonNumber}`, body, refetch); requestMedia(`${details.name}, Season ${seasonNumber}`, body, refetch);
} }
}, [requestMedia, hasAdvancedRequest, onAdvancedRequest]); }, [requestMedia, hasAdvancedRequest, onAdvancedRequest]);

View File

@@ -0,0 +1,22 @@
import { Switch, View } from "react-native";
import { ListGroup } from "../list/ListGroup";
import { useSettings } from "@/utils/atoms/settings";
import { ListItem } from "../list/ListItem";
export const ChromecastSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
return (
<View {...props}>
<ListGroup title={"Chromecast"}>
<ListItem title={"Enable H265 for Chromecast"}>
<Switch
value={settings.enableH265ForChromecast}
onValueChange={(enableH265ForChromecast) =>
updateSettings({ enableH265ForChromecast })
}
/>
</ListItem>
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,30 @@
import { useSettings } from "@/utils/atoms/settings";
import { useRouter } from "expo-router";
import React from "react";
import { View } from "react-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
export const Dashboard = () => {
const [settings, updateSettings] = useSettings();
const { sessions = [], isLoading } = useSessions({} as useSessionsProps);
const router = useRouter();
const { t } = useTranslation();
if (!settings) return null;
return (
<View>
<ListGroup title={t("home.settings.dashboard.title")} className="mt-4">
<ListItem
className={sessions.length != 0 ? "bg-purple-900" : ""}
onPress={() => router.push("/settings/dashboard/sessions")}
title={t("home.settings.dashboard.sessions_title")}
showArrow
/>
</ListGroup>
</View>
);
};

View File

@@ -62,7 +62,7 @@ export default function DownloadSettings({ ...props }) {
sideOffset={8} sideOffset={8}
> >
<DropdownMenu.Label> <DropdownMenu.Label>
{t("home.settings.downloads.methods")} {t("home.settings.downloads.download_method")}
</DropdownMenu.Label> </DropdownMenu.Label>
<DropdownMenu.Item <DropdownMenu.Item
key="1" key="1"

View File

@@ -0,0 +1,5 @@
import React from "react";
export default function DownloadSettings({ ...props }) {
return <></>;
}

View File

@@ -0,0 +1,507 @@
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 { eventBus } from "@/utils/eventBus";
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,
usePathname,
useRouter,
useSegments,
} from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type ScrollingCollectionListSection = {
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 const HomeIndex = () => {
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();
const scrollViewRef = useRef<ScrollView>(null);
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 segments = useSegments();
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if (segments[2] === "(home)")
scrollViewRef.current?.scrollTo({ y: -152, animated: true });
});
return () => {
unsubscribe();
};
}, [segments]);
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,
});
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>
);
if (l1)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
scrollToOverflowEnabled={true}
ref={scrollViewRef}
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

@@ -0,0 +1,453 @@
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 { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { 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 { useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type ScrollingCollectionListSection = {
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 const HomeIndex = () => {
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 insets = useSafeAreaInsets();
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,
});
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>
);
if (l1)
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

@@ -26,9 +26,6 @@ export const JellyseerrSettings = () => {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [settings, updateSettings, pluginSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
useState<boolean>(false);
const [jellyseerrPassword, setJellyseerrPassword] = useState< const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined string | undefined
>(undefined); >(undefined);
@@ -39,11 +36,16 @@ export const JellyseerrSettings = () => {
const loginToJellyseerrMutation = useMutation({ const loginToJellyseerrMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!jellyseerrServerUrl || !user?.Name || !jellyseerrPassword) { if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
throw new Error("Missing server url");
if (!user?.Name)
throw new Error("Missing required information for login"); throw new Error("Missing required information for login");
} const jellyseerrTempApi = new JellyseerrApi(
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl); jellyseerrServerUrl || settings.jellyseerrServerUrl || ""
return jellyseerrTempApi.login(user.Name, jellyseerrPassword); );
const testResult = await jellyseerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
}, },
onSuccess: (user) => { onSuccess: (user) => {
setJellyseerrUser(user); setJellyseerrUser(user);
@@ -57,31 +59,11 @@ export const JellyseerrSettings = () => {
}, },
}); });
const testJellyseerrServerUrlMutation = useMutation({
mutationFn: async () => {
if (!jellyseerrServerUrl || jellyseerrApi) return null;
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
return jellyseerrTempApi.test();
},
onSuccess: (result) => {
if (result && result.isValid) {
if (result.requiresPass) {
setPromptForJellyseerrPass(true);
} else {
updateSettings({ jellyseerrServerUrl });
}
} else {
setPromptForJellyseerrPass(false);
setjellyseerrServerUrl(undefined);
clearAllJellyseerData();
}
},
});
const clearData = () => { const clearData = () => {
clearAllJellyseerData().finally(() => { clearAllJellyseerData().finally(() => {
setJellyseerrUser(undefined);
setJellyseerrPassword(undefined);
setjellyseerrServerUrl(undefined); setjellyseerrServerUrl(undefined);
setPromptForJellyseerrPass(false);
}); });
}; };
@@ -92,34 +74,46 @@ export const JellyseerrSettings = () => {
<> <>
<ListGroup title={"Jellyseerr"}> <ListGroup title={"Jellyseerr"}>
<ListItem <ListItem
title={t("home.settings.plugins.jellyseerr.total_media_requests")} title={t(
"home.settings.plugins.jellyseerr.total_media_requests"
)}
value={jellyseerrUser?.requestCount?.toString()} value={jellyseerrUser?.requestCount?.toString()}
/> />
<ListItem <ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")} title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
value={ value={
jellyseerrUser?.movieQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited") jellyseerrUser?.movieQuotaLimit?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
} }
/> />
<ListItem <ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_days")} title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
value={ value={
jellyseerrUser?.movieQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited") jellyseerrUser?.movieQuotaDays?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
} }
/> />
<ListItem <ListItem
title={t("home.settings.plugins.jellyseerr.tv_quota_limit")} title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")} value={
jellyseerrUser?.tvQuotaLimit?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/> />
<ListItem <ListItem
title={t("home.settings.plugins.jellyseerr.tv_quota_days")} title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
value={jellyseerrUser?.tvQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")} value={
jellyseerrUser?.tvQuotaDays?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/> />
</ListGroup> </ListGroup>
<View className="p-4"> <View className="p-4">
<Button color="red" onPress={clearData}> <Button color="red" onPress={clearData}>
{t("home.settings.plugins.jellyseerr.reset_jellyseerr_config_button")} {t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button"
)}
</Button> </Button>
</View> </View>
</> </>
@@ -128,15 +122,20 @@ export const JellyseerrSettings = () => {
<Text className="text-xs text-red-600 mb-2"> <Text className="text-xs text-red-600 mb-2">
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")} {t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text> </Text>
<Text className="font-bold mb-1">{t("home.settings.plugins.jellyseerr.server_url")}</Text> <Text className="font-bold mb-1">
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>
<View className="flex flex-col shrink mb-2"> <View className="flex flex-col shrink mb-2">
<Text className="text-xs text-gray-600"> <Text className="text-xs text-gray-600">
{t("home.settings.plugins.jellyseerr.server_url_hint")} {t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text> </Text>
</View> </View>
<Input <Input
placeholder={t("home.settings.plugins.jellyseerr.server_url_placeholder")} className="border border-neutral-800 mb-2"
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl} placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder"
)}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
defaultValue={ defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
} }
@@ -145,40 +144,20 @@ export const JellyseerrSettings = () => {
autoCapitalize="none" autoCapitalize="none"
textContentType="URL" textContentType="URL"
onChangeText={setjellyseerrServerUrl} onChangeText={setjellyseerrServerUrl}
editable={!testJellyseerrServerUrlMutation.isPending} editable={!loginToJellyseerrMutation.isPending}
/> />
<View>
<Button <Text className="font-bold mb-2">
loading={testJellyseerrServerUrlMutation.isPending} {t("home.settings.plugins.jellyseerr.password")}
disabled={testJellyseerrServerUrlMutation.isPending} </Text>
color={promptForJellyseerrPass ? "red" : "purple"}
className="h-12 mt-2"
onPress={() => {
if (promptForJellyseerrPass) {
clearData();
return;
}
testJellyseerrServerUrlMutation.mutate();
}}
style={{
marginBottom: 8,
}}
>
{promptForJellyseerrPass ? t("home.settings.plugins.jellyseerr.clear_button") : t("home.settings.plugins.jellyseerr.save_button")}
</Button>
<View
pointerEvents={promptForJellyseerrPass ? "auto" : "none"}
style={{
opacity: promptForJellyseerrPass ? 1 : 0.5,
}}
>
<Text className="font-bold mb-2">{t("home.settings.plugins.jellyseerr.password")}</Text>
<Input <Input
className="border border-neutral-800"
autoFocus={true} autoFocus={true}
focusable={true} focusable={true}
placeholder={t("home.settings.plugins.jellyseerr.password_placeholder", {username: user?.Name})} placeholder={t(
"home.settings.plugins.jellyseerr.password_placeholder",
{ username: user?.Name }
)}
value={jellyseerrPassword} value={jellyseerrPassword}
keyboardType="default" keyboardType="default"
secureTextEntry={true} secureTextEntry={true}
@@ -186,10 +165,7 @@ export const JellyseerrSettings = () => {
autoCapitalize="none" autoCapitalize="none"
textContentType="password" textContentType="password"
onChangeText={setJellyseerrPassword} onChangeText={setJellyseerrPassword}
editable={ editable={!loginToJellyseerrMutation.isPending}
!loginToJellyseerrMutation.isPending &&
promptForJellyseerrPass
}
/> />
<Button <Button
loading={loginToJellyseerrMutation.isPending} loading={loginToJellyseerrMutation.isPending}

View File

@@ -1,5 +1,5 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import { ScreenOrientationEnum, useSettings, VideoPlayer } from "@/utils/atoms/settings";
import { BitrateSelector, BITRATES } from "@/components/BitrateSelector"; import { BitrateSelector, BITRATES } from "@/components/BitrateSelector";
import { import {
BACKGROUND_FETCH_TASK, BACKGROUND_FETCH_TASK,
@@ -7,9 +7,7 @@ import {
unregisterBackgroundFetchAsync, unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks"; } from "@/utils/background-tasks";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
const BackgroundFetch = !Platform.isTV const BackgroundFetch = !Platform.isTV ? require("expo-background-fetch") : null;
? require("expo-background-fetch")
: null;
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null; const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
@@ -22,6 +20,7 @@ import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import Dropdown from "@/components/common/Dropdown"; import Dropdown from "@/components/common/Dropdown";
import { isNumber } from "lodash";
export const OtherSettings: React.FC = () => { export const OtherSettings: React.FC = () => {
const router = useRouter(); const router = useRouter();
@@ -84,10 +83,7 @@ export const OtherSettings: React.FC = () => {
return ( return (
<DisabledSetting disabled={disabled}> <DisabledSetting disabled={disabled}>
<ListGroup title={t("home.settings.other.other_title")} className=""> <ListGroup title={t("home.settings.other.other_title")} className="">
<ListItem <ListItem title={t("home.settings.other.auto_rotate")} disabled={pluginSettings?.autoRotate?.locked}>
title={t("home.settings.other.auto_rotate")}
disabled={pluginSettings?.autoRotate?.locked}
>
<Switch <Switch
value={settings.autoRotate} value={settings.autoRotate}
disabled={pluginSettings?.autoRotate?.locked} disabled={pluginSettings?.autoRotate?.locked}
@@ -97,17 +93,11 @@ export const OtherSettings: React.FC = () => {
<ListItem <ListItem
title={t("home.settings.other.video_orientation")} title={t("home.settings.other.video_orientation")}
disabled={ disabled={pluginSettings?.defaultVideoOrientation?.locked || settings.autoRotate}
pluginSettings?.defaultVideoOrientation?.locked ||
settings.autoRotate
}
> >
<Dropdown <Dropdown
data={orientations} data={orientations}
disabled={ disabled={pluginSettings?.defaultVideoOrientation?.locked || settings.autoRotate}
pluginSettings?.defaultVideoOrientation?.locked ||
settings.autoRotate
}
keyExtractor={String} keyExtractor={String}
titleExtractor={(item) => ScreenOrientationEnum[item]} titleExtractor={(item) => ScreenOrientationEnum[item]}
title={ title={
@@ -115,17 +105,11 @@ export const OtherSettings: React.FC = () => {
<Text className="mr-1 text-[#8E8D91]"> <Text className="mr-1 text-[#8E8D91]">
{t(ScreenOrientationEnum[settings.defaultVideoOrientation])} {t(ScreenOrientationEnum[settings.defaultVideoOrientation])}
</Text> </Text>
<Ionicons <Ionicons name="chevron-expand-sharp" size={18} color="#5A5960" />
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity> </TouchableOpacity>
} }
label={t("home.settings.other.orientation")} label={t("home.settings.other.orientation")}
onSelected={(defaultVideoOrientation) => onSelected={(defaultVideoOrientation) => updateSettings({ defaultVideoOrientation })}
updateSettings({ defaultVideoOrientation })
}
/> />
</ListItem> </ListItem>
@@ -136,27 +120,49 @@ export const OtherSettings: React.FC = () => {
<Switch <Switch
value={settings.safeAreaInControlsEnabled} value={settings.safeAreaInControlsEnabled}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked} disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
onValueChange={(value) => onValueChange={(value) => updateSettings({ safeAreaInControlsEnabled: value })}
updateSettings({ safeAreaInControlsEnabled: value })
}
/> />
</ListItem> </ListItem>
{/* {(Platform.OS === "ios" || Platform.isTVOS)&& (
<ListItem
title={t("home.settings.other.video_player")}
disabled={pluginSettings?.defaultPlayer?.locked}
>
<Dropdown
data={Object.values(VideoPlayer).filter(isNumber)}
disabled={pluginSettings?.defaultPlayer?.locked}
keyExtractor={String}
titleExtractor={(item) => t(`home.settings.other.video_players.${VideoPlayer[item]}`)}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{t(`home.settings.other.video_players.${VideoPlayer[settings.defaultPlayer]}`)}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
}
label={t("home.settings.other.orientation")}
onSelected={(defaultPlayer) =>
updateSettings({ defaultPlayer })
}
/>
</ListItem>
)} */}
<ListItem <ListItem
title={t("home.settings.other.show_custom_menu_links")} title={t("home.settings.other.show_custom_menu_links")}
disabled={pluginSettings?.showCustomMenuLinks?.locked} disabled={pluginSettings?.showCustomMenuLinks?.locked}
onPress={() => onPress={() => Linking.openURL("https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links")}
Linking.openURL(
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
)
}
> >
<Switch <Switch
value={settings.showCustomMenuLinks} value={settings.showCustomMenuLinks}
disabled={pluginSettings?.showCustomMenuLinks?.locked} disabled={pluginSettings?.showCustomMenuLinks?.locked}
onValueChange={(value) => onValueChange={(value) => updateSettings({ showCustomMenuLinks: value })}
updateSettings({ showCustomMenuLinks: value })
}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
@@ -164,10 +170,7 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.hide_libraries")} title={t("home.settings.other.hide_libraries")}
showArrow showArrow
/> />
<ListItem <ListItem title={t("home.settings.other.default_quality")} disabled={pluginSettings?.defaultBitrate?.locked}>
title="Default quality"
disabled={pluginSettings?.defaultBitrate?.locked}
>
<Dropdown <Dropdown
data={BITRATES} data={BITRATES}
disabled={pluginSettings?.defaultBitrate?.locked} disabled={pluginSettings?.defaultBitrate?.locked}
@@ -176,17 +179,11 @@ export const OtherSettings: React.FC = () => {
selected={settings.defaultBitrate} selected={settings.defaultBitrate}
title={ title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3"> <TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]"> <Text className="mr-1 text-[#8E8D91]">{settings.defaultBitrate?.key}</Text>
{settings.defaultBitrate?.key} <Ionicons name="chevron-expand-sharp" size={18} color="#5A5960" />
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity> </TouchableOpacity>
} }
label={t("home.settings.other.quality")} label={t("home.settings.other.default_quality")}
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })} onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
/> />
</ListItem> </ListItem>
@@ -197,9 +194,7 @@ export const OtherSettings: React.FC = () => {
<Switch <Switch
value={settings.disableHapticFeedback} value={settings.disableHapticFeedback}
disabled={pluginSettings?.disableHapticFeedback?.locked} disabled={pluginSettings?.disableHapticFeedback?.locked}
onValueChange={(disableHapticFeedback) => onValueChange={(disableHapticFeedback) => updateSettings({ disableHapticFeedback })}
updateSettings({ disableHapticFeedback })
}
/> />
</ListItem> </ListItem>
</ListGroup> </ListGroup>

View File

@@ -8,6 +8,7 @@ import { toast } from "sonner-native";
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"; import { useTranslation } from "react-i18next";
import {Colors} from "@/constants/Colors";
export const StorageSettings = () => { export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload(); const { deleteAllFiles, appSizeUsage } = useDownload();
@@ -61,7 +62,7 @@ export const StorageSettings = () => {
<View <View
style={{ style={{
width: `${(size.app / size.total) * 100}%`, width: `${(size.app / size.total) * 100}%`,
backgroundColor: "rgb(147 51 234)", backgroundColor: Colors.primaryRGB,
}} }}
/> />
<View <View
@@ -70,7 +71,7 @@ export const StorageSettings = () => {
((size.total - size.remaining - size.app) / size.total) * ((size.total - size.remaining - size.app) / size.total) *
100 100
}%`, }%`,
backgroundColor: "rgb(192 132 252)", backgroundColor: Colors.primaryLightRGB,
}} }}
/> />
</> </>

View File

@@ -1,65 +1,40 @@
import { Text } from "@/components/common/Text"; import {Text} from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import {Loader} from "@/components/Loader";
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes"; import {useAdjacentItems} from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper"; import {useCreditSkipper} from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic"; import {useHaptic} from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import {useIntroSkipper} from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay"; import {useTrickplay} from "@/hooks/useTrickplay";
import { import {TrackInfo, VlcPlayerViewRef,} from "@/modules/VlcPlayer.types";
TrackInfo, import {apiAtom} from "@/providers/JellyfinProvider";
VlcPlayerViewRef, import {useSettings, VideoPlayer} from "@/utils/atoms/settings";
} from "@/modules/vlc-player/src/VlcPlayer.types"; import {getDefaultPlaySettings,} from "@/utils/jellyfin/getDefaultPlaySettings";
import { apiAtom } from "@/providers/JellyfinProvider"; import {getItemById} from "@/utils/jellyfin/user-library/getItemById";
import { useSettings } from "@/utils/atoms/settings"; import {writeToLog} from "@/utils/log";
import { import {formatTimeString, msToTicks, secondsToMs, ticksToMs, ticksToSeconds,} from "@/utils/time";
getDefaultPlaySettings, import {Ionicons, MaterialIcons} from "@expo/vector-icons";
previousIndexes, import {BaseItemDto, MediaSourceInfo,} from "@jellyfin/sdk/lib/generated-client";
} from "@/utils/jellyfin/getDefaultPlaySettings"; import {Image} from "expo-image";
import { getItemById } from "@/utils/jellyfin/user-library/getItemById"; import {useLocalSearchParams, useRouter} from "expo-router";
import { writeToLog } from "@/utils/log";
import {
formatTimeString,
msToTicks,
secondsToMs,
ticksToMs,
ticksToSeconds,
} from "@/utils/time";
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai"; import {useAtom} from "jotai";
import { debounce } from "lodash"; import {debounce} from "lodash";
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, {useCallback, useEffect, useRef, useState} from "react";
import { import {Platform, TouchableOpacity, useWindowDimensions, View,} from "react-native";
Platform, import {Slider} from "react-native-awesome-slider";
TouchableOpacity, import {runOnJS, SharedValue, useAnimatedReaction, useSharedValue,} from "react-native-reanimated";
useWindowDimensions, import {useSafeAreaInsets} from "react-native-safe-area-context";
View, import {VideoRef} from "react-native-video";
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
SharedValue,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { VideoRef } from "react-native-video";
import AudioSlider from "./AudioSlider"; import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider"; import BrightnessSlider from "./BrightnessSlider";
import { ControlProvider } from "./contexts/ControlContext"; import {ControlProvider} from "./contexts/ControlContext";
import { VideoProvider } from "./contexts/VideoContext"; import {VideoProvider} from "./contexts/VideoContext";
import DropdownView from "./dropdown/DropdownView"; import DropdownView from "./dropdown/DropdownView";
import { EpisodeList } from "./EpisodeList"; import {EpisodeList} from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton"; import SkipButton from "./SkipButton";
import { useControlsTimeout } from "./useControlsTimeout"; import {useControlsTimeout} from "./useControlsTimeout";
import { VideoTouchOverlay } from "./VideoTouchOverlay"; import {VideoTouchOverlay} from "./VideoTouchOverlay";
interface Props { interface Props {
item: BaseItemDto; item: BaseItemDto;
@@ -87,40 +62,38 @@ interface Props {
setSubtitleURL?: (url: string, customName: string) => void; setSubtitleURL?: (url: string, customName: string) => void;
setSubtitleTrack?: (index: number) => void; setSubtitleTrack?: (index: number) => void;
setAudioTrack?: (index: number) => void; setAudioTrack?: (index: number) => void;
stop: (() => Promise<void>) | (() => void);
isVlc?: boolean; isVlc?: boolean;
} }
const CONTROLS_TIMEOUT = 4000; const CONTROLS_TIMEOUT = 4000;
export const Controls: React.FC<Props> = ({ export const Controls: React.FC<Props> = ({
item, item,
seek, seek,
startPictureInPicture, startPictureInPicture,
play, play,
pause, pause,
togglePlay, togglePlay,
isPlaying, isPlaying,
isSeeking, isSeeking,
progress, progress,
isBuffering, isBuffering,
cacheProgress, cacheProgress,
showControls, showControls,
setShowControls, setShowControls,
ignoreSafeAreas, ignoreSafeAreas,
setIgnoreSafeAreas, setIgnoreSafeAreas,
mediaSource, mediaSource,
isVideoLoaded, isVideoLoaded,
getAudioTracks, getAudioTracks,
getSubtitleTracks, getSubtitleTracks,
setSubtitleURL, setSubtitleURL,
setSubtitleTrack, setSubtitleTrack,
setAudioTrack, setAudioTrack,
stop, offline = false,
offline = false, enableTrickplay = true,
enableTrickplay = true, isVlc = false,
isVlc = false, }) => {
}) => {
const [settings] = useSettings(); const [settings] = useSettings();
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -189,75 +162,60 @@ export const Controls: React.FC<Props> = ({
isVlc isVlc
); );
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
if (!item || !settings) return;
lightHapticFeedback();
const previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
item,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue.toString(),
}).toString();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
},
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router]
);
const goToPreviousItem = useCallback(() => { const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return; if (!previousItem) return;
goToItemCommon(previousItem);
lightHapticFeedback(); }, [previousItem, goToItemCommon]);
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
previousItem,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: previousItem.Id ?? "", // Ensure itemId is a string
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue.toString(),
}).toString();
stop();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
}, [previousItem, settings, subtitleIndex, audioIndex]);
const goToNextItem = useCallback(() => { const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return; if (!nextItem) return;
goToItemCommon(nextItem);
}, [nextItem, goToItemCommon]);
lightHapticFeedback(); const goToItem = useCallback(
async (itemId: string) => {
const previousIndexes: previousIndexes = { const gotoItem = await getItemById(api, itemId);
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, if (!gotoItem) return;
audioIndex: audioIndex ? parseInt(audioIndex) : undefined, goToItemCommon(gotoItem);
}; },
[goToItemCommon, api]
const { );
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
nextItem,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: nextItem.Id ?? "", // Ensure itemId is a string
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue.toString(),
}).toString();
stop();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
}, [nextItem, settings, subtitleIndex, audioIndex]);
const updateTimes = useCallback( const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => { (currentProgress: number, maxValue: number) => {
@@ -381,49 +339,6 @@ export const Controls: React.FC<Props> = ({
} }
}, [settings, isPlaying, isVlc]); }, [settings, isPlaying, isVlc]);
const goToItem = useCallback(
async (itemId: string) => {
try {
const gotoItem = await getItemById(api, itemId);
if (!settings || !gotoItem) return;
lightHapticFeedback();
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
gotoItem,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: gotoItem.Id ?? "", // Ensure itemId is a string
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue.toString(),
}).toString();
stop();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
} catch (error) {
console.error("Error in gotoEpisode:", error);
}
},
[settings, subtitleIndex, audioIndex]
);
const toggleIgnoreSafeAreas = useCallback(() => { const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev); setIgnoreSafeAreas((prev) => !prev);
lightHapticFeedback(); lightHapticFeedback();
@@ -497,7 +412,6 @@ export const Controls: React.FC<Props> = ({
}, [trickPlayUrl, trickplayInfo, time]); }, [trickPlayUrl, trickplayInfo, time]);
const onClose = async () => { const onClose = async () => {
stop();
lightHapticFeedback(); lightHapticFeedback();
await ScreenOrientation.lockAsync( await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP ScreenOrientation.OrientationLock.PORTRAIT_UP
@@ -540,21 +454,26 @@ export const Controls: React.FC<Props> = ({
pointerEvents={showControls ? "auto" : "none"} pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row w-full pt-2`} className={`flex flex-row w-full pt-2`}
> >
<View className="mr-auto"> {!Platform.isTV && (
<VideoProvider <View className="mr-auto">
getAudioTracks={getAudioTracks} <VideoProvider
getSubtitleTracks={getSubtitleTracks} getAudioTracks={getAudioTracks}
setAudioTrack={setAudioTrack} getSubtitleTracks={getSubtitleTracks}
setSubtitleTrack={setSubtitleTrack} setAudioTrack={setAudioTrack}
setSubtitleURL={setSubtitleURL} setSubtitleTrack={setSubtitleTrack}
> setSubtitleURL={setSubtitleURL}
<DropdownView showControls={showControls} /> >
</VideoProvider> <DropdownView />
</View> </VideoProvider>
</View>
)}
<View className="flex flex-row items-center space-x-2 "> <View className="flex flex-row items-center space-x-2 ">
{!Platform.isTV && ( {!Platform.isTV && settings.defaultPlayer == VideoPlayer.VLC_4 && (
<TouchableOpacity onPress={startPictureInPicture}> <TouchableOpacity
onPress={startPictureInPicture}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<MaterialIcons <MaterialIcons
name="picture-in-picture" name="picture-in-picture"
size={24} size={24}
@@ -785,8 +704,8 @@ export const Controls: React.FC<Props> = ({
!nextItem !nextItem
? false ? false
: isVlc : isVlc
? remainingTime < 10000 ? remainingTime < 10000
: remainingTime < 10 : remainingTime < 10
} }
onFinish={goToNextItem} onFinish={goToNextItem}
onPress={goToNextItem} onPress={goToNextItem}

View File

@@ -1,4 +1,3 @@
import { TrackInfo } from "@/modules/vlc-player";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,

View File

@@ -1,16 +1,5 @@
import { TrackInfo } from "@/modules/vlc-player"; import { TrackInfo } from "@/modules/VlcPlayer.types";
import { import React, { createContext, useContext, useState, ReactNode, useEffect, useMemo } from "react";
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import React, {
createContext,
useContext,
useState,
ReactNode,
useEffect,
useMemo,
} from "react";
import { useControlContext } from "./ControlContext"; import { useControlContext } from "./ControlContext";
import { Track } from "../types"; import { Track } from "../types";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
@@ -27,14 +16,8 @@ const VideoContext = createContext<VideoContextProps | undefined>(undefined);
interface VideoProviderProps { interface VideoProviderProps {
children: ReactNode; children: ReactNode;
getAudioTracks: getAudioTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined;
| (() => Promise<TrackInfo[] | null>) getSubtitleTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined;
| (() => TrackInfo[])
| undefined;
getSubtitleTracks:
| (() => Promise<TrackInfo[] | null>)
| (() => TrackInfo[])
| undefined;
setAudioTrack: ((index: number) => void) | undefined; setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined; setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined; setSubtitleURL: ((url: string, customName: string) => void) | undefined;
@@ -55,23 +38,19 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
const isVideoLoaded = ControlContext?.isVideoLoaded; const isVideoLoaded = ControlContext?.isVideoLoaded;
const mediaSource = ControlContext?.mediaSource; const mediaSource = ControlContext?.mediaSource;
const allSubs = const allSubs = mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
const { itemId, audioIndex, bitrateValue, subtitleIndex } = const { itemId, audioIndex, bitrateValue, subtitleIndex } = useLocalSearchParams<{
useLocalSearchParams<{ itemId: string;
itemId: string; audioIndex: string;
audioIndex: string; subtitleIndex: string;
subtitleIndex: string; mediaSourceId: string;
mediaSourceId: string; bitrateValue: string;
bitrateValue: string; }>();
}>();
const onTextBasedSubtitle = useMemo( const onTextBasedSubtitle = useMemo(
() => () =>
allSubs.find( allSubs.find((s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream) || subtitleIndex === "-1",
(s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream
) || subtitleIndex === "-1",
[allSubs, subtitleIndex] [allSubs, subtitleIndex]
); );
@@ -95,21 +74,14 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
router.replace(`player/direct-player?${queryParams}`); router.replace(`player/direct-player?${queryParams}`);
}; };
const setTrackParams = ( const setTrackParams = (type: "audio" | "subtitle", index: number, serverIndex: number) => {
type: "audio" | "subtitle",
index: number,
serverIndex: number
) => {
const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack; const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack;
const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex"; const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex";
// If we're transcoding and we're going from a image based subtitle // If we're transcoding and we're going from a image based subtitle
// to a text based subtitle, we need to change the player params. // to a text based subtitle, we need to change the player params.
const shouldChangePlayerParams = const shouldChangePlayerParams = type === "subtitle" && mediaSource?.TranscodingUrl && !onTextBasedSubtitle;
type === "subtitle" &&
mediaSource?.TranscodingUrl &&
!onTextBasedSubtitle;
console.log("Set player params", index, serverIndex); console.log("Set player params", index, serverIndex);
if (shouldChangePlayerParams) { if (shouldChangePlayerParams) {
@@ -129,23 +101,22 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
if (getSubtitleTracks) { if (getSubtitleTracks) {
const subtitleData = await getSubtitleTracks(); const subtitleData = await getSubtitleTracks();
// Step 1: Move external subs to the end, because VLC puts external subs at the end
const sortedSubs = allSubs.sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal));
// Step 2: Apply VLC indexing logic
let textSubIndex = 0; let textSubIndex = 0;
const subtitles: Track[] = allSubs?.map((sub) => { const processedSubs: Track[] = sortedSubs?.map((sub) => {
// Always increment for non-transcoding subtitles // Always increment for non-transcoding subtitles
// Only increment for text-based subtitles when transcoding // Only increment for text-based subtitles when transcoding
const shouldIncrement = const shouldIncrement = !mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
!mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
const displayTitle = sub.DisplayTitle || "Undefined Subtitle";
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1; const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1;
const finalIndex = shouldIncrement ? vlcIndex : sub.Index ?? -1; const finalIndex = shouldIncrement ? vlcIndex : sub.Index ?? -1;
if (shouldIncrement) textSubIndex++; if (shouldIncrement) textSubIndex++;
return { return {
name: displayTitle, name: sub.DisplayTitle || "Undefined Subtitle",
index: sub.Index ?? -1, index: sub.Index ?? -1,
originalIndex: finalIndex,
setTrack: () => setTrack: () =>
shouldIncrement shouldIncrement
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1) ? setTrackParams("subtitle", finalIndex, sub.Index ?? -1)
@@ -155,6 +126,9 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
}; };
}); });
// Step 3: Restore the original order
const subtitles: Track[] = processedSubs.sort((a, b) => a.index - b.index);
// Add a "Disable Subtitles" option // Add a "Disable Subtitles" option
subtitles.unshift({ subtitles.unshift({
name: "Disable", name: "Disable",
@@ -164,36 +138,25 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
? setTrackParams("subtitle", -1, -1) ? setTrackParams("subtitle", -1, -1)
: setPlayerParams({ chosenSubtitleIndex: "-1" }), : setPlayerParams({ chosenSubtitleIndex: "-1" }),
}); });
setSubtitleTracks(subtitles); setSubtitleTracks(subtitles);
} }
if ( if (getAudioTracks) {
getAudioTracks &&
(audioTracks === null || audioTracks.length === 0)
) {
const audioData = await getAudioTracks(); const audioData = await getAudioTracks();
if (!audioData) return;
console.log("audioData", audioData);
const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
const allAudio = mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
const audioTracks: Track[] = allAudio?.map((audio, idx) => { const audioTracks: Track[] = allAudio?.map((audio, idx) => {
if (!mediaSource?.TranscodingUrl) { if (!mediaSource?.TranscodingUrl) {
const vlcIndex = audioData?.at(idx)?.index ?? -1; const vlcIndex = audioData?.at(idx)?.index ?? -1;
return { return {
name: audio.DisplayTitle ?? "Undefined Audio", name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1, index: audio.Index ?? -1,
setTrack: () => setTrack: () => setTrackParams("audio", vlcIndex, audio.Index ?? -1),
setTrackParams("audio", vlcIndex, audio.Index ?? -1),
}; };
} }
return { return {
name: audio.DisplayTitle ?? "Undefined Audio", name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1, index: audio.Index ?? -1,
setTrack: () => setTrack: () => setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
}; };
}); });
setAudioTracks(audioTracks); setAudioTracks(audioTracks);

View File

@@ -1,23 +1,20 @@
import React from "react"; import React, { useCallback } from "react";
import { TouchableOpacity, Platform } from "react-native"; import { TouchableOpacity, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useVideoContext } from "../contexts/VideoContext"; import { useVideoContext } from "../contexts/VideoContext";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { BITRATES } from "@/components/BitrateSelector";
import { useControlContext } from "../contexts/ControlContext";
interface DropdownViewProps { const DropdownView = () => {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownView: React.FC<DropdownViewProps> = ({
showControls,
offline = false,
}) => {
const videoContext = useVideoContext(); const videoContext = useVideoContext();
const { subtitleTracks, audioTracks } = videoContext; const { subtitleTracks, audioTracks } = videoContext;
const ControlContext = useControlContext();
const [item, mediaSource] = [ControlContext?.item, ControlContext?.mediaSource];
const router = useRouter();
const { subtitleIndex, audioIndex } = useLocalSearchParams<{ const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
subtitleIndex: string; subtitleIndex: string;
@@ -25,6 +22,21 @@ const DropdownView: React.FC<DropdownViewProps> = ({
bitrateValue: string; bitrateValue: string;
}>(); }>();
const changeBitrate = useCallback(
(bitrate: string) => {
const queryParams = new URLSearchParams({
itemId: item.Id ?? "",
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "",
bitrateValue: bitrate.toString(),
}).toString();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
},
[item, mediaSource, subtitleIndex, audioIndex]
);
return ( return (
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
@@ -42,9 +54,27 @@ const DropdownView: React.FC<DropdownViewProps> = ({
sideOffset={8} sideOffset={8}
> >
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger"> <DropdownMenu.SubTrigger key="qualitytrigger">Quality</DropdownMenu.SubTrigger>
Subtitle <DropdownMenu.SubContent
</DropdownMenu.SubTrigger> alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{BITRATES?.map((bitrate, idx: number) => (
<DropdownMenu.CheckboxItem
key={`quality-item-${idx}`}
value={bitrateValue === (bitrate.value?.toString() ?? "")}
onValueChange={() => changeBitrate(bitrate.value?.toString() ?? "")}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>{bitrate.key}</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">Subtitle</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent <DropdownMenu.SubContent
alignOffset={-10} alignOffset={-10}
avoidCollisions={true} avoidCollisions={true}
@@ -58,17 +88,13 @@ const DropdownView: React.FC<DropdownViewProps> = ({
value={subtitleIndex === sub.index.toString()} value={subtitleIndex === sub.index.toString()}
onValueChange={() => sub.setTrack()} onValueChange={() => sub.setTrack()}
> >
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}> <DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>{sub.name}</DropdownMenu.ItemTitle>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
))} ))}
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
</DropdownMenu.Sub> </DropdownMenu.Sub>
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger"> <DropdownMenu.SubTrigger key="audio-trigger">Audio</DropdownMenu.SubTrigger>
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent <DropdownMenu.SubContent
alignOffset={-10} alignOffset={-10}
avoidCollisions={true} avoidCollisions={true}
@@ -82,9 +108,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({
value={audioIndex === track.index.toString()} value={audioIndex === track.index.toString()}
onValueChange={() => track.setTrack()} onValueChange={() => track.setTrack()}
> >
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}> <DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>{track.name}</DropdownMenu.ItemTitle>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
))} ))}
</DropdownMenu.SubContent> </DropdownMenu.SubContent>

View File

@@ -1,7 +1,7 @@
import { import {
TrackInfo, TrackInfo,
VlcPlayerViewRef, VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types"; } from "@/modules/VlcPlayer.types";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native"; import { TouchableOpacity, View, ViewProps } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";

View File

@@ -1,5 +1,7 @@
export const Colors = { export const Colors = {
primary: "#9334E9", primary: "#9334E9",
primaryRGB: "rgb(147 51 234)",
primaryLightRGB: "rgb(192 132 252)",
text: "#ECEDEE", text: "#ECEDEE",
background: "#151718", background: "#151718",
tint: "#fff", tint: "#fff",

View File

@@ -32,20 +32,20 @@
} }
}, },
"production": { "production": {
"channel": "0.26.1", "channel": "0.27.0",
"android": { "android": {
"image": "latest" "image": "latest"
} }
}, },
"production-apk": { "production-apk": {
"channel": "0.26.1", "channel": "0.27.0",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"
} }
}, },
"production-apk-tv": { "production-apk-tv": {
"channel": "0.26.1", "channel": "0.27.0",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"

109
hooks/useFavorite.ts Normal file
View File

@@ -0,0 +1,109 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useEffect, useState, useMemo } from "react";
export const useFavorite = (item: BaseItemDto) => {
const queryClient = useQueryClient();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const type = "item";
const [isFavorite, setIsFavorite] = useState(item.UserData?.IsFavorite);
useEffect(() => {
setIsFavorite(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"] });
setIsFavorite(true);
},
});
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"] });
setIsFavorite(false);
},
});
const toggleFavorite = () => {
if (isFavorite) {
unmarkFavoriteMutation.mutate();
} else {
markFavoriteMutation.mutate();
}
};
return {
isFavorite,
toggleFavorite,
markFavoriteMutation,
unmarkFavoriteMutation,
};
};

View File

@@ -1,5 +1,5 @@
import axios, { AxiosError, AxiosInstance } from "axios"; import axios, { AxiosError, AxiosInstance } from "axios";
import { Results } from "@/utils/jellyseerr/server/models/Search"; import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { inRange } from "lodash"; import { inRange } from "lodash";
import { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User"; import { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
@@ -14,7 +14,7 @@ import {
MediaType, MediaType,
} from "@/utils/jellyseerr/server/constants/media"; } from "@/utils/jellyseerr/server/constants/media";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import {MediaRequestBody, RequestResultsResponse} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { import {
SeasonWithEpisodes, SeasonWithEpisodes,
@@ -227,6 +227,23 @@ export class JellyseerrApi {
.then(({ data }) => data); .then(({ data }) => data);
} }
async getRequest(id: number): Promise<MediaRequest> {
return this.axios
?.get<MediaRequest>(Endpoints.API_V1 + Endpoints.REQUEST + `/${id}`)
.then(({ data }) => data);
}
async requests(params = {
filter: "all",
take: 10,
sort: "modified",
skip: 0
}): Promise<RequestResultsResponse> {
return this.axios
?.get<RequestResultsResponse>(Endpoints.API_V1 + Endpoints.REQUEST, {params})
.then(({data}) => data);
}
async movieDetails(id: number) { async movieDetails(id: number) {
return this.axios return this.axios
?.get<MovieDetails>(Endpoints.API_V1 + Endpoints.MOVIE + `/${id}`) ?.get<MovieDetails>(Endpoints.API_V1 + Endpoints.MOVIE + `/${id}`)
@@ -439,22 +456,56 @@ export const useJellyseerr = () => {
); );
const isJellyseerrResult = ( const isJellyseerrResult = (
items: any[] | null | undefined items: any | null | undefined
): items is Results[] => { ): items is Results => {
return ( return (
!items || items &&
(items.length >= 0 && Object.hasOwn(items, "mediaType") &&
Object.hasOwn(items[0], "mediaType") && Object.values(MediaType).includes(items["mediaType"])
Object.values(MediaType).includes(items[0]["mediaType"])) )
);
}; };
const getTitle = (item: TvResult | TvDetails | MovieResult | MovieDetails) => {
return isJellyseerrResult(item)
? (item.mediaType == MediaType.MOVIE ? item?.originalTitle : item?.name)
: (item.mediaInfo.mediaType == MediaType.MOVIE ? (item as MovieDetails)?.title : (item as TvDetails)?.name)
};
const getYear = (item: TvResult | TvDetails | MovieResult | MovieDetails) => {
return new Date((
isJellyseerrResult(item)
? (item.mediaType == MediaType.MOVIE ? item?.releaseDate : item?.firstAirDate)
: (item.mediaInfo.mediaType == MediaType.MOVIE ? (item as MovieDetails)?.releaseDate : (item as TvDetails)?.firstAirDate))
|| ""
)?.getFullYear?.()
};
const getMediaType = (item: TvResult | TvDetails | MovieResult | MovieDetails): MediaType => {
return isJellyseerrResult(item)
? item.mediaType
: item?.mediaInfo?.mediaType
};
const jellyseerrRegion = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const jellyseerrLocale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
return { return {
jellyseerrApi, jellyseerrApi,
jellyseerrUser, jellyseerrUser,
setJellyseerrUser, setJellyseerrUser,
clearAllJellyseerData, clearAllJellyseerData,
isJellyseerrResult, isJellyseerrResult,
getTitle,
getYear,
getMediaType,
jellyseerrRegion,
jellyseerrLocale,
requestMedia, requestMedia,
}; };
}; };

View File

@@ -11,7 +11,9 @@ import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
// import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native"; // import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
const FFMPEGKitReactNative = !Platform.isTV ? require("ffmpeg-kit-react-native") : null; const FFMPEGKitReactNative = !Platform.isTV
? require("ffmpeg-kit-react-native")
: null;
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback } from "react"; import { useCallback } from "react";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
@@ -24,8 +26,10 @@ import { Platform } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession; type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession;
type Statistics = typeof FFMPEGKitReactNative.Statistics type Statistics = typeof FFMPEGKitReactNative.Statistics;
const FFmpegKit = FFMPEGKitReactNative.FFmpegKit; const FFmpegKit = Platform.isTV
? null
: (FFMPEGKitReactNative.FFmpegKit as typeof FFMPEGKitReactNative.FFmpegKit);
const createFFmpegCommand = (url: string, output: string) => [ const createFFmpegCommand = (url: string, output: string) => [
"-y", // overwrite output files without asking "-y", // overwrite output files without asking
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options "-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
@@ -101,7 +105,10 @@ export const useRemuxHlsToMp4 = () => {
} }
setProcesses((prev: any[]) => { setProcesses((prev: any[]) => {
return prev.filter((process: { itemId: string | undefined; }) => process.itemId !== item.Id); return prev.filter(
(process: { itemId: string | undefined }) =>
process.itemId !== item.Id
);
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -126,7 +133,7 @@ export const useRemuxHlsToMp4 = () => {
if (!item.Id) throw new Error("Item is undefined"); if (!item.Id) throw new Error("Item is undefined");
setProcesses((prev: any[]) => { setProcesses((prev: any[]) => {
return prev.map((process: { itemId: string | undefined; }) => { return prev.map((process: { itemId: string | undefined }) => {
if (process.itemId === item.Id) { if (process.itemId === item.Id) {
return { return {
...process, ...process,
@@ -161,15 +168,18 @@ export const useRemuxHlsToMp4 = () => {
// First lets save any important assets we want to present to the user offline // First lets save any important assets we want to present to the user offline
await onSaveAssets(api, item); await onSaveAssets(api, item);
toast.success(t("home.downloads.toasts.download_started_for", {item: item.Name}), { toast.success(
action: { t("home.downloads.toasts.download_started_for", { item: item.Name }),
label: "Go to download", {
onClick: () => { action: {
router.push("/downloads"); label: "Go to download",
toast.dismiss(); onClick: () => {
router.push("/downloads");
toast.dismiss();
},
}, },
}, }
}); );
try { try {
const job: JobStatus = { const job: JobStatus = {
@@ -201,7 +211,10 @@ export const useRemuxHlsToMp4 = () => {
Error: ${error.message}, Stack: ${error.stack}` Error: ${error.message}, Stack: ${error.stack}`
); );
setProcesses((prev: any[]) => { setProcesses((prev: any[]) => {
return prev.filter((process: { itemId: string | undefined; }) => process.itemId !== item.Id); return prev.filter(
(process: { itemId: string | undefined }) =>
process.itemId !== item.Id
);
}); });
throw error; // Re-throw the error to propagate it to the caller throw error; // Re-throw the error to propagate it to the caller
} }

33
hooks/useSessions.ts Normal file
View File

@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { userAtom } from "@/providers/JellyfinProvider";
export interface useSessionsProps {
refetchInterval: number;
activeWithinSeconds: number;
}
export const useSessions = ({ refetchInterval = 5 * 1000, activeWithinSeconds = 360 }: useSessionsProps) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data, isLoading } = useQuery({
queryKey: ["sessions"],
queryFn: async () => {
if (!api || !user || !user.Policy?.IsAdministrator) {
return [];
}
const response = await getSessionApi(api).getSessions({
activeWithinSeconds: activeWithinSeconds,
});
return response.data
.filter((s) => s.NowPlayingItem)
.sort((a, b) => (b.NowPlayingItem?.Name ?? "").localeCompare(a.NowPlayingItem?.Name ?? ""));
},
refetchInterval: refetchInterval,
});
return { sessions: data, isLoading };
};

15
i18n.ts
View File

@@ -5,7 +5,12 @@ import de from "./translations/de.json";
import en from "./translations/en.json"; import en from "./translations/en.json";
import es from "./translations/es.json"; import es from "./translations/es.json";
import fr from "./translations/fr.json"; import fr from "./translations/fr.json";
import it from "./translations/it.json";
import ja from "./translations/ja.json";
import nl from "./translations/nl.json";
import sv from "./translations/sv.json"; import sv from "./translations/sv.json";
import zhCN from './translations/zh-CN.json';
import zhTW from './translations/zh-TW.json';
import { getLocales } from "expo-localization"; import { getLocales } from "expo-localization";
export const APP_LANGUAGES = [ export const APP_LANGUAGES = [
@@ -13,7 +18,12 @@ export const APP_LANGUAGES = [
{ label: "English", value: "en" }, { label: "English", value: "en" },
{ label: "Español", value: "es" }, { label: "Español", value: "es" },
{ label: "Français", value: "fr" }, { label: "Français", value: "fr" },
{ label: "Italiano", value: "it" },
{ label: "日本語", value: "ja" },
{ label: "Nederlands", value: "nl" },
{ label: "Svenska", value: "sv" }, { label: "Svenska", value: "sv" },
{ label: "简体中文", value: "zh-CN" },
{ label: "繁體中文", value: "zh-TW" },
]; ];
i18n.use(initReactI18next).init({ i18n.use(initReactI18next).init({
@@ -23,7 +33,12 @@ i18n.use(initReactI18next).init({
en: { translation: en }, en: { translation: en },
es: { translation: es }, es: { translation: es },
fr: { translation: fr }, fr: { translation: fr },
it: { translation: it },
ja: { translation: ja },
nl: { translation: nl },
sv: { translation: sv }, sv: { translation: sv },
"zh-CN": { translation: zhCN },
"zh-TW": { translation: zhTW },
}, },
lng: getLocales()[0].languageCode || "en", lng: getLocales()[0].languageCode || "en",

6
login.yaml Normal file
View File

@@ -0,0 +1,6 @@
# login.yaml
appId: your.app.id
---
- launchApp
- tapOn: "Text on the screen"

View File

@@ -6,16 +6,30 @@ import {
VlcPlayerViewRef, VlcPlayerViewRef,
VlcPlayerSource, VlcPlayerSource,
} from "./VlcPlayer.types"; } from "./VlcPlayer.types";
import {useSettings, VideoPlayer} from "@/utils/atoms/settings";
import {Platform} from "react-native";
interface NativeViewRef extends VlcPlayerViewRef { interface NativeViewRef extends VlcPlayerViewRef {
setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void; setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void;
} }
const NativeViewManager = requireNativeViewManager("VlcPlayer"); const VLCViewManager = requireNativeViewManager("VlcPlayer");
const VLC3ViewManager = requireNativeViewManager("VlcPlayer3");
// Create a forwarded ref version of the native view // Create a forwarded ref version of the native view
const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>( const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>(
(props, ref) => <NativeViewManager {...props} ref={ref} /> (props, ref) => {
const [settings] = useSettings();
if (Platform.OS === "ios" || Platform.isTVOS) {
if (settings.defaultPlayer == VideoPlayer.VLC_3) {
console.log("[Apple] Using Vlc Player 3")
return <VLC3ViewManager {...props} ref={ref}/>
}
}
console.log("Using default Vlc Player")
return <VLCViewManager {...props} ref={ref}/>
}
); );
const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>( const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(

27
modules/index.ts Normal file
View File

@@ -0,0 +1,27 @@
import VlcPlayerView from "./VlcPlayerView";
import {
PlaybackStatePayload,
ProgressUpdatePayload,
VideoLoadStartPayload,
VideoStateChangePayload,
VideoProgressPayload,
VlcPlayerSource,
TrackInfo,
ChapterInfo,
VlcPlayerViewProps,
VlcPlayerViewRef,
} from "./VlcPlayer.types";
export {
VlcPlayerView,
VlcPlayerViewProps,
VlcPlayerViewRef,
PlaybackStatePayload,
ProgressUpdatePayload,
VideoLoadStartPayload,
VideoStateChangePayload,
VideoProgressPayload,
VlcPlayerSource,
TrackInfo,
ChapterInfo,
};

View File

@@ -0,0 +1,6 @@
{
"platforms": ["ios", "tvos"],
"ios": {
"modules": ["VlcPlayer3Module"]
}
}

View File

@@ -0,0 +1,23 @@
Pod::Spec.new do |s|
s.name = 'VlcPlayer3'
s.version = '3.6.1b1'
s.summary = 'A sample project summary'
s.description = 'A sample project description'
s.author = ''
s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.ios.dependency 'MobileVLCKit', s.version
s.tvos.dependency 'TVVLCKit', s.version
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
end

View File

@@ -0,0 +1,71 @@
import ExpoModulesCore
public class VlcPlayer3Module: Module {
public func definition() -> ModuleDefinition {
Name("VlcPlayer3")
View(VlcPlayer3View.self) {
Prop("source") { (view: VlcPlayer3View, source: [String: Any]) in
view.setSource(source)
}
Prop("paused") { (view: VlcPlayer3View, paused: Bool) in
if paused {
view.pause()
} else {
view.play()
}
}
Events(
"onPlaybackStateChanged",
"onVideoStateChange",
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError",
"onPipStarted"
)
AsyncFunction("startPictureInPicture") { (view: VlcPlayer3View) in
view.startPictureInPicture()
}
AsyncFunction("play") { (view: VlcPlayer3View) in
view.play()
}
AsyncFunction("pause") { (view: VlcPlayer3View) in
view.pause()
}
AsyncFunction("stop") { (view: VlcPlayer3View) in
view.stop()
}
AsyncFunction("seekTo") { (view: VlcPlayer3View, time: Int32) in
view.seekTo(time)
}
AsyncFunction("setAudioTrack") { (view: VlcPlayer3View, trackIndex: Int) in
view.setAudioTrack(trackIndex)
}
AsyncFunction("getAudioTracks") { (view: VlcPlayer3View) -> [[String: Any]]? in
return view.getAudioTracks()
}
AsyncFunction("setSubtitleTrack") { (view: VlcPlayer3View, trackIndex: Int) in
view.setSubtitleTrack(trackIndex)
}
AsyncFunction("getSubtitleTracks") { (view: VlcPlayer3View) -> [[String: Any]]? in
return view.getSubtitleTracks()
}
AsyncFunction("setSubtitleURL") {
(view: VlcPlayer3View, url: String, name: String) in
view.setSubtitleURL(url, name: name)
}
}
}
}

View File

@@ -0,0 +1,388 @@
import ExpoModulesCore
#if os(tvOS)
import TVVLCKit
#else
import MobileVLCKit
#endif
import UIKit
class VlcPlayer3View: ExpoView {
private var mediaPlayer: VLCMediaPlayer?
private var videoView: UIView?
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
private var isPaused: Bool = false
private var currentGeometryCString: [CChar]?
private var lastReportedState: VLCMediaPlayerState?
private var lastReportedIsPlaying: Bool?
private var customSubtitles: [(internalName: String, originalName: String)] = []
private var startPosition: Int32 = 0
private var isMediaReady: Bool = false
private var externalTrack: [String: String]?
private var progressTimer: DispatchSourceTimer?
private var isStopping: Bool = false // Define isStopping here
private var lastProgressCall = Date().timeIntervalSince1970
var hasSource = false
// MARK: - Initialization
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
setupNotifications()
}
// MARK: - Setup
private func setupView() {
DispatchQueue.main.async {
self.backgroundColor = .black
self.videoView = UIView()
self.videoView?.translatesAutoresizingMaskIntoConstraints = false
if let videoView = self.videoView {
self.addSubview(videoView)
NSLayoutConstraint.activate([
videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
videoView.topAnchor.constraint(equalTo: self.topAnchor),
videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
])
}
}
}
private func setupNotifications() {
NotificationCenter.default.addObserver(
self, selector: #selector(applicationWillResignActive),
name: UIApplication.willResignActiveNotification, object: nil)
NotificationCenter.default.addObserver(
self, selector: #selector(applicationDidBecomeActive),
name: UIApplication.didBecomeActiveNotification, object: nil)
}
// MARK: - Public Methods
func startPictureInPicture() { }
@objc func play() {
self.mediaPlayer?.play()
self.isPaused = false
print("Play")
}
@objc func pause() {
self.mediaPlayer?.pause()
self.isPaused = true
}
@objc func seekTo(_ time: Int32) {
guard let player = self.mediaPlayer else { return }
let wasPlaying = player.isPlaying
if wasPlaying {
self.pause()
}
if let duration = player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime)
if wasPlaying {
self.play()
}
self.updatePlayerState()
} else {
print("Error: Unable to retrieve video duration")
}
}
@objc func setSource(_ source: [String: Any]) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.hasSource {
return
}
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
self.externalTrack = source["externalTrack"] as? [String: String]
var initOptions = source["initOptions"] as? [Any] ?? []
self.startPosition = source["startPosition"] as? Int32 ?? 0
initOptions.append("--start-time=\(self.startPosition)")
guard let uri = source["uri"] as? String, !uri.isEmpty else {
print("Error: Invalid or empty URI")
self.onVideoError?(["error": "Invalid or empty URI"])
return
}
let autoplay = source["autoplay"] as? Bool ?? false
let isNetwork = source["isNetwork"] as? Bool ?? false
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
self.mediaPlayer?.delegate = self
self.mediaPlayer?.drawable = self.videoView
self.mediaPlayer?.scaleFactor = 0
let media: VLCMedia
if isNetwork {
print("Loading network file: \(uri)")
media = VLCMedia(url: URL(string: uri)!)
} else {
print("Loading local file: \(uri)")
if uri.starts(with: "file://"), let url = URL(string: uri) {
media = VLCMedia(url: url)
} else {
media = VLCMedia(path: uri)
}
}
print("Debug: Media options: \(mediaOptions)")
media.addOptions(mediaOptions)
self.mediaPlayer?.media = media
self.hasSource = true
if autoplay {
print("Playing...")
self.play()
}
}
}
@objc func setAudioTrack(_ trackIndex: Int) {
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
}
@objc func getAudioTracks() -> [[String: Any]]? {
guard let trackNames = mediaPlayer?.audioTrackNames,
let trackIndexes = mediaPlayer?.audioTrackIndexes
else {
return nil
}
return zip(trackNames, trackIndexes).map { name, index in
return ["name": name, "index": index]
}
}
@objc func setSubtitleTrack(_ trackIndex: Int) {
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
print(
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
)
}
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else {
print("Error: Invalid subtitle URL")
return
}
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
if let result = result {
let internalName = "Track \(self.customSubtitles.count + 1)"
print("Subtitle added with result: \(result) \(internalName)")
self.customSubtitles.append((internalName: internalName, originalName: name))
} else {
print("Failed to add subtitle")
}
}
@objc func getSubtitleTracks() -> [[String: Any]]? {
guard let mediaPlayer = self.mediaPlayer else {
return nil
}
let count = mediaPlayer.numberOfSubtitlesTracks
print("Debug: Number of subtitle tracks: \(count)")
guard count > 0 else {
return nil
}
var tracks: [[String: Any]] = []
if let names = mediaPlayer.videoSubTitlesNames as? [String],
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
{
for (index, name) in zip(indexes, names) {
if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
} else {
tracks.append(["name": name, "index": index.intValue])
}
}
}
print("Debug: Subtitle tracks: \(tracks)")
return tracks
}
@objc func stop(completion: (() -> Void)? = nil) {
guard !isStopping else {
completion?()
return
}
isStopping = true
// If we're not on the main thread, dispatch to main thread
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.performStop(completion: completion)
}
} else {
performStop(completion: completion)
}
}
// MARK: - Private Methods
@objc private func applicationWillResignActive() {
}
@objc private func applicationDidBecomeActive() {
}
private func performStop(completion: (() -> Void)? = nil) {
// Stop the media player
mediaPlayer?.stop()
// Remove observer
NotificationCenter.default.removeObserver(self)
// Clear the video view
videoView?.removeFromSuperview()
videoView = nil
// Release the media player
mediaPlayer?.delegate = nil
mediaPlayer = nil
isStopping = false
completion?()
}
private func updateVideoProgress() {
guard let player = self.mediaPlayer else { return }
let currentTimeMs = player.time.intValue
let durationMs = player.media?.length.intValue ?? 0
print("Debug: Current time: \(currentTimeMs)")
if currentTimeMs >= 0 && currentTimeMs < durationMs {
if player.isPlaying && !self.isMediaReady {
self.isMediaReady = true
// Set external track subtitle when starting.
if let externalTrack = self.externalTrack {
if let name = externalTrack["name"], !name.isEmpty {
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
self.setSubtitleURL(deliveryUrl, name: name)
}
}
}
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
}
// MARK: - Expo Events
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
@objc var onVideoLoadStart: RCTDirectEventBlock?
@objc var onVideoStateChange: RCTDirectEventBlock?
@objc var onVideoProgress: RCTDirectEventBlock?
@objc var onVideoLoadEnd: RCTDirectEventBlock?
@objc var onVideoError: RCTDirectEventBlock?
@objc var onPipStarted: RCTDirectEventBlock?
// MARK: - Deinitialization
deinit {
performStop()
}
}
extension VlcPlayer3View: VLCMediaPlayerDelegate {
func mediaPlayerTimeChanged(_ aNotification: Notification) {
// self?.updateVideoProgress()
let timeNow = Date().timeIntervalSince1970
if timeNow - lastProgressCall >= 1 {
lastProgressCall = timeNow
updateVideoProgress()
}
}
func mediaPlayerStateChanged(_ aNotification: Notification) {
self.updatePlayerState()
}
private func updatePlayerState() {
guard let player = self.mediaPlayer else { return }
let currentState = player.state
var stateInfo: [String: Any] = [
"target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0,
"error": false,
]
if player.isPlaying {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing"
} else {
stateInfo["isPlaying"] = false
stateInfo["state"] = "Paused"
}
if player.state == VLCMediaPlayerState.buffering {
stateInfo["isBuffering"] = true
stateInfo["state"] = "Buffering"
} else if player.state == VLCMediaPlayerState.error {
print("player.state ~ error")
stateInfo["state"] = "Error"
self.onVideoLoadEnd?(stateInfo)
} else if player.state == VLCMediaPlayerState.opening {
print("player.state ~ opening")
stateInfo["state"] = "Opening"
}
if self.lastReportedState != currentState
|| self.lastReportedIsPlaying != player.isPlaying
{
self.lastReportedState = currentState
self.lastReportedIsPlaying = player.isPlaying
self.onVideoStateChange?(stateInfo)
}
}
}
extension VlcPlayer3View: VLCMediaDelegate {
// Implement VLCMediaDelegate methods if needed
}
extension VLCMediaPlayerState {
var description: String {
switch self {
case .opening: return "Opening"
case .buffering: return "Buffering"
case .playing: return "Playing"
case .paused: return "Paused"
case .stopped: return "Stopped"
case .ended: return "Ended"
case .error: return "Error"
case .esAdded: return "ESAdded"
@unknown default: return "Unknown"
}
}
}

View File

@@ -0,0 +1,5 @@
import { requireNativeModule } from 'expo-modules-core';
// It loads the native module object from the JSI or falls back to
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
export default requireNativeModule('VlcPlayer3');

View File

@@ -1,2 +0,0 @@
#Sun Nov 17 18:25:45 AEDT 2024
gradle.version=8.9

View File

@@ -1,7 +1,8 @@
{ {
"platforms": ["ios", "tvos", "android", "web"], "platforms": ["ios", "tvos", "android", "web"],
"ios": { "ios": {
"modules": ["VlcPlayerModule"] "modules": ["VlcPlayerModule"],
"appDelegateSubscribers": ["AppLifecycleDelegate"]
}, },
"android": { "android": {
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"] "modules": ["expo.modules.vlcplayer.VlcPlayerModule"]

View File

@@ -1,68 +0,0 @@
import {
EventEmitter,
EventSubscription,
} from "expo-modules-core";
import VlcPlayerModule from "./src/VlcPlayerModule";
import VlcPlayerView from "./src/VlcPlayerView";
import {
PlaybackStatePayload,
ProgressUpdatePayload,
VideoLoadStartPayload,
VideoStateChangePayload,
VideoProgressPayload,
VlcPlayerSource,
TrackInfo,
ChapterInfo,
VlcPlayerViewProps,
VlcPlayerViewRef,
} from "./src/VlcPlayer.types";
const emitter = new EventEmitter(VlcPlayerModule);
export function addPlaybackStateListener(
listener: (event: PlaybackStatePayload) => void
): EventSubscription {
return emitter.addListener<PlaybackStatePayload>(
"onPlaybackStateChanged",
listener
);
}
export function addVideoLoadStartListener(
listener: (event: VideoLoadStartPayload) => void
): EventSubscription {
return emitter.addListener<VideoLoadStartPayload>(
"onVideoLoadStart",
listener
);
}
export function addVideoStateChangeListener(
listener: (event: VideoStateChangePayload) => void
): EventSubscription {
return emitter.addListener<VideoStateChangePayload>(
"onVideoStateChange",
listener
);
}
export function addVideoProgressListener(
listener: (event: VideoProgressPayload) => void
): EventSubscription {
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
}
export {
VlcPlayerView,
VlcPlayerViewProps,
VlcPlayerViewRef,
PlaybackStatePayload,
ProgressUpdatePayload,
VideoLoadStartPayload,
VideoStateChangePayload,
VideoProgressPayload,
VlcPlayerSource,
TrackInfo,
ChapterInfo,
};

View File

@@ -0,0 +1,32 @@
import ExpoModulesCore
protocol SimpleAppLifecycleListener {
func applicationDidEnterBackground() -> Void
func applicationDidEnterForeground() -> Void
}
public class AppLifecycleDelegate: ExpoAppDelegateSubscriber {
public func applicationDidBecomeActive(_ application: UIApplication) {
// The app has become active.
}
public func applicationWillResignActive(_ application: UIApplication) {
// The app is about to become inactive.
}
public func applicationDidEnterBackground(_ application: UIApplication) {
VLCManager.shared.listeners.forEach { listener in
listener.applicationDidEnterBackground()
}
}
public func applicationWillEnterForeground(_ application: UIApplication) {
VLCManager.shared.listeners.forEach { listener in
listener.applicationDidEnterForeground()
}
}
public func applicationWillTerminate(_ application: UIApplication) {
// The app is about to terminate.
}
}

View File

@@ -0,0 +1,4 @@
class VLCManager {
static let shared = VLCManager()
var listeners: [SimpleAppLifecycleListener] = []
}

View File

@@ -5,19 +5,19 @@ Pod::Spec.new do |s|
s.description = 'A sample project description' s.description = 'A sample project description'
s.author = '' s.author = ''
s.homepage = 'https://docs.expo.dev/modules/' s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = { :ios => '13.4', :tvos => '13.4' } s.platforms = { :ios => '13.4', :tvos => '16' }
s.source = { git: '' } s.source = { git: '' }
s.static_framework = true s.static_framework = true
s.dependency 'ExpoModulesCore' s.dependency 'ExpoModulesCore'
s.ios.dependency 'VLCKit', s.version s.ios.dependency 'VLCKit', s.version
s.tvos.dependency 'VLCKit', s.version s.tvos.dependency 'VLCKit', s.version
s.dependency 'Alamofire', '~> 5.10'
# Swift/Objective-C compatibility # Swift/Objective-C compatibility
s.pod_target_xcconfig = { s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES', 'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule' 'SWIFT_COMPILATION_MODE' => 'wholemodule'
} }
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end end

View File

@@ -1,6 +1,7 @@
import ExpoModulesCore import ExpoModulesCore
import UIKit import UIKit
import VLCKit import VLCKit
import os
public class VLCPlayerView: UIView { public class VLCPlayerView: UIView {
func setupView(parent: UIView) { func setupView(parent: UIView) {
@@ -142,6 +143,8 @@ extension VLCPlayerWrapper: VLCMediaDelegate {
} }
class VlcPlayerView: ExpoView { class VlcPlayerView: ExpoView {
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView")
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper() private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
private var isPaused: Bool = false private var isPaused: Bool = false
@@ -157,6 +160,7 @@ class VlcPlayerView: ExpoView {
super.init(appContext: appContext) super.init(appContext: appContext)
setupVLC() setupVLC()
setupNotifications() setupNotifications()
VLCManager.shared.listeners.append(self)
} }
// MARK: - Setup // MARK: - Setup
@@ -188,7 +192,7 @@ class VlcPlayerView: ExpoView {
@objc func play() { @objc func play() {
self.vlc.player.play() self.vlc.player.play()
self.isPaused = false self.isPaused = false
print("Play") logger.debug("Play")
} }
@objc func pause() { @objc func pause() {
@@ -203,7 +207,7 @@ class VlcPlayerView: ExpoView {
} }
if let duration = vlc.player.media?.length.intValue { if let duration = vlc.player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)") logger.debug("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end // If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time let seekTime = time > duration ? duration - 1000 : time
@@ -217,11 +221,12 @@ class VlcPlayerView: ExpoView {
} }
} }
} else { } else {
print("Error: Unable to retrieve video duration") logger.error("Unable to retrieve video duration")
} }
} }
@objc func setSource(_ source: [String: Any]) { @objc func setSource(_ source: [String: Any]) {
logger.debug("Setting source...")
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if self.hasSource { if self.hasSource {
@@ -241,7 +246,7 @@ class VlcPlayerView: ExpoView {
} }
guard let uri = source["uri"] as? String, !uri.isEmpty else { guard let uri = source["uri"] as? String, !uri.isEmpty else {
print("Error: Invalid or empty URI") logger.error("Invalid or empty URI")
self.onVideoError?(["error": "Invalid or empty URI"]) self.onVideoError?(["error": "Invalid or empty URI"])
return return
} }
@@ -253,10 +258,10 @@ class VlcPlayerView: ExpoView {
let media: VLCMedia! let media: VLCMedia!
if isNetwork { if isNetwork {
print("Loading network file: \(uri)") logger.debug("Loading network file: \(uri)")
media = VLCMedia(url: URL(string: uri)!) media = VLCMedia(url: URL(string: uri)!)
} else { } else {
print("Loading local file: \(uri)") logger.debug("Loading local file: \(uri)")
if uri.starts(with: "file://"), let url = URL(string: uri) { if uri.starts(with: "file://"), let url = URL(string: uri) {
media = VLCMedia(url: url) media = VLCMedia(url: url)
} else { } else {
@@ -264,14 +269,14 @@ class VlcPlayerView: ExpoView {
} }
} }
print("Debug: Media options: \(mediaOptions)") logger.debug("Media options: \(mediaOptions)")
media.addOptions(mediaOptions) media.addOptions(mediaOptions)
self.vlc.player.media = media self.vlc.player.media = media
self.setInitialExternalSubtitles() self.setInitialExternalSubtitles()
self.hasSource = true self.hasSource = true
if autoplay { if autoplay {
print("Playing...") logger.info("Playing...")
self.play() self.play()
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000)) self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
} }
@@ -291,31 +296,31 @@ class VlcPlayerView: ExpoView {
} }
@objc func setSubtitleTrack(_ trackIndex: Int) { @objc func setSubtitleTrack(_ trackIndex: Int) {
print("Debug: Attempting to set subtitle track to index: \(trackIndex)") logger.debug("Attempting to set subtitle track to index: \(trackIndex)")
if trackIndex == -1 { if trackIndex == -1 {
print("Debug: Disabling all subtitles") logger.debug("Disabling all subtitles")
for track in self.vlc.player.textTracks { for track in self.vlc.player.textTracks {
track.isSelected = false track.isSelected = false
} }
return return
} }
let track = self.vlc.player.textTracks[trackIndex] let track = self.vlc.player.textTracks[trackIndex]
track.isSelectedExclusively = true track.isSelectedExclusively = true;
print("Debug: Current subtitle track index after setting: \(track.trackName)") logger.debug("Current subtitle track index after setting: \(track.trackName)")
} }
@objc func setSubtitleURL(_ subtitleURL: String, name: String) { @objc func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else { guard let url = URL(string: subtitleURL) else {
print("Error: Invalid subtitle URL") logger.error("Invalid subtitle URL")
return return
} }
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false) let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
if result == 0 { if result == 0 {
let internalName = "Track \(self.customSubtitles.count)" let internalName = "Track \(self.customSubtitles.count)"
self.customSubtitles.append((internalName: internalName, originalName: name)) self.customSubtitles.append((internalName: internalName, originalName: name))
print("Subtitle added with result: \(result) \(internalName)") logger.debug("Subtitle added with result: \(result) \(internalName)")
} else { } else {
print("Failed to add subtitle") logger.debug("Failed to add subtitle")
} }
} }
@@ -324,7 +329,8 @@ class VlcPlayerView: ExpoView {
return nil return nil
} }
print("Debug: Number of subtitle tracks: \(self.vlc.player.textTracks.count)") logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
if let customSubtitle = customSubtitles.first(where: { if let customSubtitle = customSubtitles.first(where: {
$0.internalName == track.trackName $0.internalName == track.trackName
@@ -334,11 +340,13 @@ class VlcPlayerView: ExpoView {
return ["name": track.trackName, "index": index] return ["name": track.trackName, "index": index]
} }
} }
print("Debug: Subtitle tracks: \(tracks)")
return tracks logger.debug("Subtitle tracks: \(tracks)")
return tracks
} }
@objc func stop(completion: (() -> Void)? = nil) { @objc func stop(completion: (() -> Void)? = nil) {
logger.debug("Stopping media...")
guard !isStopping else { guard !isStopping else {
completion?() completion?()
return return
@@ -393,12 +401,12 @@ class VlcPlayerView: ExpoView {
} }
private func updateVideoProgress() { private func updateVideoProgress() {
guard let media = self.vlc.player.media else { return } guard self.vlc.player.media != nil else { return }
let currentTimeMs = self.vlc.player.time.intValue let currentTimeMs = self.vlc.player.time.intValue
let durationMs = self.vlc.player.media?.length.intValue ?? 0 let durationMs = self.vlc.player.media?.length.intValue ?? 0
print("Debug: Current time: \(currentTimeMs)") logger.debug("Current time: \(currentTimeMs)")
self.onVideoProgress?([ self.onVideoProgress?([
"currentTime": currentTimeMs, "currentTime": currentTimeMs,
"duration": durationMs, "duration": durationMs,
@@ -430,7 +438,34 @@ class VlcPlayerView: ExpoView {
// MARK: - Deinitialization // MARK: - Deinitialization
deinit { deinit {
logger.debug("Deinitialization")
performStop() performStop()
VLCManager.shared.listeners.removeAll()
}
}
// MARK: - SimpleAppLifecycleListener
extension VlcPlayerView: SimpleAppLifecycleListener {
func applicationDidEnterBackground() {
logger.debug("Entering background")
}
func applicationDidEnterForeground() {
logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)")
if !self.vlc.getPlayerView().isDescendant(of: self) {
logger.debug("Player view is missing. Adding back as subview")
self.addSubview(self.vlc.getPlayerView())
}
// Current solution to fixing black screen when re-entering application
if let videoTrack = self.vlc.player.videoTracks.first(where: { $0.isSelected == true }),
!self.vlc.isMediaPlaying()
{
videoTrack.isSelected = false
videoTrack.isSelectedExclusively = true
self.vlc.player.play()
self.vlc.player.pause()
}
} }
} }
@@ -443,6 +478,7 @@ extension VLCMediaPlayerState {
case .paused: return "Paused" case .paused: return "Paused"
case .stopped: return "Stopped" case .stopped: return "Stopped"
case .error: return "Error" case .error: return "Error"
case .stopping: return "Stopping"
@unknown default: return "Unknown" @unknown default: return "Unknown"
} }
} }

View File

@@ -12,7 +12,6 @@
"android:tv": "EXPO_TV=1 expo run:android", "android:tv": "EXPO_TV=1 expo run:android",
"prebuild": "EXPO_TV=0 bun run clean", "prebuild": "EXPO_TV=0 bun run clean",
"prebuild:tv": "EXPO_TV=1 bun run clean", "prebuild:tv": "EXPO_TV=1 bun run clean",
"prebuild:tv-new": "EXPO_TV=1 node ./scripts/symlink-native-dirs.js; bun run prebuild:tv",
"test": "jest --watchAll", "test": "jest --watchAll",
"lint": "expo lint", "lint": "expo lint",
"postinstall": "patch-package" "postinstall": "patch-package"
@@ -27,7 +26,6 @@
"@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",
@@ -35,9 +33,6 @@
"@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",
@@ -62,7 +57,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.21", "expo-splash-screen": "~0.29.22",
"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",
@@ -124,7 +119,10 @@
"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",
"typescript": "~5.7.3" "typescript": "~5.7.3",
"@types/lodash": "^4.17.15",
"@types/react-native-vector-icons": "^6.4.18",
"@types/uuid": "^10.0.0"
}, },
"private": true, "private": true,
"expo": { "expo": {

File diff suppressed because one or more lines are too long

View File

@@ -18,11 +18,13 @@ import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader";
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query"; import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import * as Application from "expo-application"; import * as Application from "expo-application";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { FileInfo } from "expo-file-system"; import { FileInfo } from "expo-file-system";
import Notifications from "expo-notifications";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import React, { import React, {
@@ -36,11 +38,6 @@ import { useTranslation } from "react-i18next";
import { AppState, AppStateStatus, Platform } from "react-native"; import { AppState, AppStateStatus, Platform } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { apiAtom } from "./JellyfinProvider"; import { apiAtom } from "./JellyfinProvider";
const BackGroundDownloader = !Platform.isTV
? (require("@kesha-antonov/react-native-background-downloader") as typeof import("@kesha-antonov/react-native-background-downloader"))
: null;
// import * as Notifications from "expo-notifications";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
export type DownloadedItem = { export type DownloadedItem = {
item: Partial<BaseItemDto>; item: Partial<BaseItemDto>;
@@ -58,8 +55,6 @@ const DownloadContext = createContext<ReturnType<
> | null>(null); > | null>(null);
function useDownloadProvider() { function useDownloadProvider() {
if (Platform.isTV) return;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation(); const { t } = useTranslation();
const [settings] = useSettings(); const [settings] = useSettings();
@@ -747,5 +742,8 @@ export function useDownload() {
if (context === null) { if (context === null) {
throw new Error("useDownload must be used within a DownloadProvider"); throw new Error("useDownload must be used within a DownloadProvider");
} }
if (Platform.isTV) {
throw new Error("useDownload is not supported on TVOS");
}
return context; return context;
} }

View File

@@ -0,0 +1,107 @@
import { storage } from "@/utils/mmkv";
import { JobStatus } from "@/utils/optimize-server";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import * as Application from "expo-application";
import * as FileSystem from "expo-file-system";
import { atom, useAtom } from "jotai";
import React, { createContext, useCallback, useContext, useMemo } from "react";
export type DownloadedItem = {
item: Partial<BaseItemDto>;
mediaSource: MediaSourceInfo;
};
export const processesAtom = atom<JobStatus[]>([]);
const DownloadContext = createContext<ReturnType<
typeof useDownloadProvider
> | null>(null);
/**
* Dummy download provider for tvOS
*/
function useDownloadProvider() {
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const downloadedFiles: DownloadedItem[] = [];
const removeProcess = useCallback(async (id: string) => {}, []);
const startDownload = useCallback(async (process: JobStatus) => {
return null;
}, []);
const startBackgroundDownload = useCallback(
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
return null;
},
[]
);
const deleteAllFiles = async (): Promise<void> => {};
const deleteFile = async (id: string): Promise<void> => {};
const deleteItems = async (items: BaseItemDto[]) => {};
const cleanCacheDirectory = async () => {};
const deleteFileByType = async (type: BaseItemDto["Type"]) => {};
const appSizeUsage = useMemo(async () => {
return 0;
}, []);
function getDownloadedItem(itemId: string): DownloadedItem | null {
return null;
}
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {}
function getDownloadedItemSize(itemId: string): number {
const size = storage.getString("downloadedItemSize-" + itemId);
return size ? parseInt(size) : 0;
}
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
return {
processes,
startBackgroundDownload,
downloadedFiles,
deleteAllFiles,
deleteFile,
deleteItems,
saveDownloadedItemInfo,
removeProcess,
setProcesses,
startDownload,
getDownloadedItem,
deleteFileByType,
appSizeUsage,
getDownloadedItemSize,
APP_CACHE_DOWNLOAD_DIRECTORY,
cleanCacheDirectory,
};
}
export function DownloadProvider({ children }: { children: React.ReactNode }) {
const downloadProviderValue = useDownloadProvider();
return (
<DownloadContext.Provider value={downloadProviderValue}>
{children}
</DownloadContext.Provider>
);
}
export function useDownload() {
const context = useContext(DownloadContext);
if (context === null) {
throw new Error("useDownload must be used within a DownloadProvider");
}
return context;
}

View File

@@ -1,5 +1,7 @@
import "@/augmentations"; import "@/augmentations";
import { useInterval } from "@/hooks/useInterval"; import { useInterval } from "@/hooks/useInterval";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useSettings } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { Api, Jellyfin } from "@jellyfin/sdk"; import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models"; import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
@@ -7,6 +9,7 @@ import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
import { router, useSegments } from "expo-router"; import { router, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import React, { import React, {
createContext, createContext,
@@ -17,16 +20,10 @@ import React, {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { Platform } from "react-native";
import uuid from "react-native-uuid";
import { getDeviceName } from "react-native-device-info";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSettings } from "@/utils/atoms/settings"; import { Platform } from "react-native";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; import { getDeviceName } from "react-native-device-info";
import { import uuid from "react-native-uuid";
useSplashScreenLoading,
useSplashScreenVisible,
} from "./SplashScreenProvider";
interface Server { interface Server {
address: string; address: string;
@@ -64,7 +61,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin( setJellyfin(
() => () =>
new Jellyfin({ new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.26.1" }, clientInfo: { name: "Streamyfin", version: "0.27.0" },
deviceInfo: { deviceInfo: {
name: deviceName, name: deviceName,
id, id,
@@ -88,28 +85,12 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
] = useSettings(); ] = useSettings();
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(); const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
useQuery({
queryKey: ["user", api],
queryFn: async () => {
if (!api) return null;
const response = await getUserApi(api).getCurrentUser();
if (response.data) setUser(response.data);
return user;
},
enabled: !!api,
refetchOnWindowFocus: true,
refetchInterval: 1000 * 60,
refetchIntervalInBackground: true,
refetchOnMount: true,
refetchOnReconnect: true,
});
const headers = useMemo(() => { const headers = useMemo(() => {
if (!deviceId) return {}; if (!deviceId) return {};
return { return {
authorization: `MediaBrowser Client="Streamyfin", Device=${ authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS" Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.26.1"`, }, DeviceId="${deviceId}", Version="0.27.0"`,
}; };
}, [deviceId]); }, [deviceId]);
@@ -179,14 +160,13 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
} }
}, [api, secret, headers]); }, [api, secret, headers]);
useInterval(pollQuickConnect, isPolling ? 1000 : null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await refreshStreamyfinPluginSettings(); await refreshStreamyfinPluginSettings();
})(); })();
}, []); }, []);
useInterval(pollQuickConnect, isPolling ? 1000 : null);
useInterval(refreshStreamyfinPluginSettings, 60 * 5 * 1000); // 5 min useInterval(refreshStreamyfinPluginSettings, 60 * 5 * 1000); // 5 min
const discoverServers = async (url: string): Promise<Server[]> => { const discoverServers = async (url: string): Promise<Server[]> => {
@@ -303,6 +283,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
mutationFn: async () => { mutationFn: async () => {
storage.delete("token"); storage.delete("token");
setUser(null); setUser(null);
setApi(null);
setPluginSettings(undefined); setPluginSettings(undefined);
await clearAllJellyseerData(); await clearAllJellyseerData();
}, },
@@ -311,33 +292,44 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}, },
}); });
const { isLoading, isFetching } = useQuery({ const [loaded, setLoaded] = useState(false);
queryKey: [ const [initialLoaded, setInitialLoaded] = useState(false);
"initializeJellyfin",
user?.Id, useEffect(() => {
api?.basePath, if (initialLoaded) {
jellyfin?.clientInfo, setLoaded(true);
], }
queryFn: async () => { }, [initialLoaded]);
useEffect(() => {
const initializeJellyfin = async () => {
if (!jellyfin) return;
try { try {
const token = getTokenFromStorage(); const token = getTokenFromStorage();
const serverUrl = getServerUrlFromStorage(); const serverUrl = getServerUrlFromStorage();
const user = getUserFromStorage(); const storedUser = getUserFromStorage();
if (serverUrl && token && user?.Id && jellyfin) {
if (serverUrl && token) {
const apiInstance = jellyfin.createApi(serverUrl, token); const apiInstance = jellyfin.createApi(serverUrl, token);
setApi(apiInstance); setApi(apiInstance);
setUser(user);
}
return true; if (storedUser?.Id) {
setUser(storedUser);
}
const response = await getUserApi(apiInstance).getCurrentUser();
setUser(response.data);
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return false; } finally {
setInitialLoaded(true);
} }
}, };
staleTime: 0,
enabled: !user?.Id || !api || !jellyfin, initializeJellyfin();
}); }, [jellyfin]);
const contextValue: JellyfinContextValue = { const contextValue: JellyfinContextValue = {
discoverServers, discoverServers,
@@ -349,17 +341,17 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
initiateQuickConnect, initiateQuickConnect,
}; };
let isLoadingOrFetching = isLoading || isFetching; useEffect(() => {
useProtectedRoute(user, isLoadingOrFetching); if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
// show splash screen until everything loaded useProtectedRoute(user, loaded);
useSplashScreenLoading(isLoadingOrFetching);
const splashScreenVisible = useSplashScreenVisible();
return ( return (
<JellyfinContext.Provider value={contextValue}> <JellyfinContext.Provider value={contextValue}>
{/* don't render login page when loading and splash screen visible */} {children}
{isLoadingOrFetching && splashScreenVisible ? undefined : children}
</JellyfinContext.Provider> </JellyfinContext.Provider>
); );
}; };
@@ -371,20 +363,24 @@ export const useJellyfin = (): JellyfinContextValue => {
return context; return context;
}; };
function useProtectedRoute(user: UserDto | null, loading = false) { function useProtectedRoute(user: UserDto | null, loaded = false) {
const segments = useSegments(); const segments = useSegments();
useEffect(() => { useEffect(() => {
if (loading) return; if (loaded === false) return;
console.log("Loaded", user);
const inAuthGroup = segments[0] === "(auth)"; const inAuthGroup = segments[0] === "(auth)";
if (!user?.Id && inAuthGroup) { if (!user?.Id && inAuthGroup) {
console.log("Redirected to login");
router.replace("/login"); router.replace("/login");
} else if (user?.Id && !inAuthGroup) { } else if (user?.Id && !inAuthGroup) {
console.log("Redirected to home");
router.replace("/(auth)/(tabs)/(home)/"); router.replace("/(auth)/(tabs)/(home)/");
} }
}, [user, segments, loading]); }, [user, segments, loaded]);
} }
export function getTokenFromStorage(): string | null { export function getTokenFromStorage(): string | null {

View File

@@ -1,103 +0,0 @@
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
useRef,
} from "react";
import * as SplashScreen from "expo-splash-screen";
type SplashScreenContextValue = {
registerLoadingComponent: () => () => void;
splashScreenVisible: boolean;
};
const SplashScreenContext = createContext<SplashScreenContextValue | undefined>(
undefined
);
// Prevent splash screen from auto-hiding
void SplashScreen.preventAutoHideAsync();
export const SplashScreenProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [splashScreenVisible, setSplashScreenVisible] = useState(true);
const loadingComponentsCount = useRef(0);
const isHidingRef = useRef(false);
const hideScreenIfNoLoadingComponents = async () => {
if (loadingComponentsCount.current === 0 && !isHidingRef.current) {
try {
isHidingRef.current = true;
await SplashScreen.hideAsync();
setSplashScreenVisible(false);
} catch (error) {
console.warn("Failed to hide splash screen:", error);
} finally {
isHidingRef.current = false;
}
}
};
const registerLoadingComponent = () => {
loadingComponentsCount.current += 1;
return () => {
loadingComponentsCount.current -= 1;
void hideScreenIfNoLoadingComponents();
};
};
const contextValue: SplashScreenContextValue = {
registerLoadingComponent,
splashScreenVisible,
};
return (
<SplashScreenContext.Provider value={contextValue}>
{children}
</SplashScreenContext.Provider>
);
};
/**
* Show the Splash Screen until component is ready to be displayed.
*
* @param isLoading The loading state of the component
*
* ## Usage
* ```
* const isLoading = loadSomething()
* useSplashScreenLoading(isLoading) // splash screen visible until isLoading is false
* ```
*/
export function useSplashScreenLoading(isLoading: boolean) {
const context = useContext(SplashScreenContext);
if (!context) {
throw new Error(
"useSplashScreenLoading must be used within a SplashScreenProvider"
);
}
useEffect(() => {
if (isLoading) {
return context.registerLoadingComponent();
}
}, [isLoading]);
}
/**
* Get the visibility of the Splash Screen.
* @returns the visibility of the Splash Screen
*/
export function useSplashScreenVisible() {
const context = useContext(SplashScreenContext);
if (!context) {
throw new Error(
"useSplashScreenVisible must be used within a SplashScreenProvider"
);
}
return context.splashScreenVisible;
}

View File

@@ -128,11 +128,17 @@
"OTHER": "Andere", "OTHER": "Andere",
"UNKNOWN": "Unbekannt" "UNKNOWN": "Unbekannt"
}, },
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen", "safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
"video_player": "Video player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen", "show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
"hide_libraries": "Bibliotheken ausblenden", "hide_libraries": "Bibliotheken ausblenden",
"select_liraries_you_want_to_hide": "Wähl die Bibliotheken aus, die du im Bibliothekstab und auf der Startseite ausblenden möchtest.", "select_liraries_you_want_to_hide": "Wähl die Bibliotheken aus, die du im Bibliothekstab und auf der Startseite ausblenden möchtest.",
"disable_haptic_feedback": "Haptisches Feedback deaktivieren" "disable_haptic_feedback": "Haptisches Feedback deaktivieren",
"default_quality": "Standardqualität"
}, },
"downloads": { "downloads": {
"downloads_title": "Downloads", "downloads_title": "Downloads",
@@ -167,7 +173,8 @@
"tv_quota_limit": "TV-Anfragelimit", "tv_quota_limit": "TV-Anfragelimit",
"tv_quota_days": "TV-Anfragetage", "tv_quota_days": "TV-Anfragetage",
"reset_jellyseerr_config_button": "Setze Jellyseerr-Konfiguration zurück", "reset_jellyseerr_config_button": "Setze Jellyseerr-Konfiguration zurück",
"unlimited": "Unlimitiert" "unlimited": "Unlimitiert",
"plus_n_more": "+{{n}} more"
}, },
"marlin_search": { "marlin_search": {
"enable_marlin_search": "Aktiviere Marlin Search", "enable_marlin_search": "Aktiviere Marlin Search",
@@ -354,7 +361,7 @@
"index": "Index:" "index": "Index:"
}, },
"item_card": { "item_card": {
"next_up": "Als nächstes", "next_up": "Als Nächstes",
"no_items_to_display": "Keine Elemente zum Anzeigen", "no_items_to_display": "Keine Elemente zum Anzeigen",
"cast_and_crew": "Besetzung und Crew", "cast_and_crew": "Besetzung und Crew",
"series": "Serien", "series": "Serien",
@@ -432,7 +439,7 @@
"tags": "Tags", "tags": "Tags",
"quality_profile": "Qualitätsprofil", "quality_profile": "Qualitätsprofil",
"root_folder": "Root-Ordner", "root_folder": "Root-Ordner",
"season_x": "Staffel {{seasons}}", "season_all": "Season (all)",
"season_number": "Staffel {{season_number}}", "season_number": "Staffel {{season_number}}",
"number_episodes": "{{episode_number}} Episodes", "number_episodes": "{{episode_number}} Episodes",
"born": "Geboren", "born": "Geboren",

View File

@@ -129,10 +129,16 @@
"UNKNOWN": "Unknown" "UNKNOWN": "Unknown"
}, },
"safe_area_in_controls": "Safe area in controls", "safe_area_in_controls": "Safe area in controls",
"video_player": "Video player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Show Custom Menu Links", "show_custom_menu_links": "Show Custom Menu Links",
"hide_libraries": "Hide Libraries", "hide_libraries": "Hide Libraries",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.", "select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable Haptic Feedback" "disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default quality",
}, },
"downloads": { "downloads": {
"downloads_title": "Downloads", "downloads_title": "Downloads",
@@ -146,7 +152,7 @@
"default": "Default", "default": "Default",
"optimized_version_hint": "Enter the URL for the optimize server. The URL should include http or https and optionally the port.", "optimized_version_hint": "Enter the URL for the optimize server. The URL should include http or https and optionally the port.",
"read_more_about_optimized_server": "Read more about the optimize server.", "read_more_about_optimized_server": "Read more about the optimize server.",
"url":"URL", "url": "URL",
"server_url_placeholder": "http(s)://domain.org:port" "server_url_placeholder": "http(s)://domain.org:port"
}, },
"plugins": { "plugins": {
@@ -167,7 +173,8 @@
"tv_quota_limit": "TV quota limit", "tv_quota_limit": "TV quota limit",
"tv_quota_days": "TV quota days", "tv_quota_days": "TV quota days",
"reset_jellyseerr_config_button": "Reset Jellyseerr config", "reset_jellyseerr_config_button": "Reset Jellyseerr config",
"unlimited": "Unlimited" "unlimited": "Unlimited",
"plus_n_more": "+{{n}} more"
}, },
"marlin_search": { "marlin_search": {
"enable_marlin_search": "Enable Marlin Search ", "enable_marlin_search": "Enable Marlin Search ",
@@ -203,14 +210,18 @@
"app_language_description": "Select the language for the app.", "app_language_description": "Select the language for the app.",
"system": "System" "system": "System"
}, },
"toasts":{ "toasts": {
"error_deleting_files": "Error deleting files", "error_deleting_files": "Error deleting files",
"background_downloads_enabled": "Background downloads enabled", "background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled", "background_downloads_disabled": "Background downloads disabled",
"connected": "Connected", "connected": "Connected",
"could_not_connect": "Could not connect", "could_not_connect": "Could not connect",
"invalid_url": "Invalid URL" "invalid_url": "Invalid URL"
} },
},
"sessions": {
"title": "Sessions",
"no_active_sessions": "No active sessions"
}, },
"downloads": { "downloads": {
"downloads_title": "Downloads", "downloads_title": "Downloads",
@@ -398,7 +409,7 @@
"for_kids": "For Kids", "for_kids": "For Kids",
"news": "News" "news": "News"
}, },
"jellyseerr":{ "jellyseerr": {
"confirm": "Confirm", "confirm": "Confirm",
"cancel": "Cancel", "cancel": "Cancel",
"yes": "Yes", "yes": "Yes",
@@ -432,7 +443,7 @@
"tags": "Tags", "tags": "Tags",
"quality_profile": "Quality Profile", "quality_profile": "Quality Profile",
"root_folder": "Root Folder", "root_folder": "Root Folder",
"season_x": "Season {{seasons}}", "season_all": "Season (all)",
"season_number": "Season {{season_number}}", "season_number": "Season {{season_number}}",
"number_episodes": "{{episode_number}} Episodes", "number_episodes": "{{episode_number}} Episodes",
"born": "Born", "born": "Born",

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