Compare commits

...

84 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
herrrta
d83ecb881b fix: Android PiP support fully working
- fixed black screen on re-entering
- ensured screen stays alive when video is playing
- PiP button states now reflect media status
2025-02-15 15:11:57 -05:00
Fredrik Burmester
4c14c08b35 fix: move from react-native-video -> VLC for transcoded streams (#529)
Co-authored-by: Alex Kim <alexkim5682@gmail.com>
2025-02-16 07:10:36 +11:00
herrrta
ecb9b90163 fix: Stop playback when gesture navigating back 2025-02-15 12:52:09 -05:00
Fredrik Burmester
33a2be24f4 fix: hidden be default ios 2025-02-15 12:00:58 +01:00
Fredrik Burmester
e8b0d52515 feat: change to native searchbar on android 2025-02-15 11:59:17 +01:00
Fredrik Burmester
9faa0de2d6 chore: bump version 2025-02-15 11:48:54 +01:00
Fredrik Burmester
221155d002 fix: deps 2025-02-15 11:34:50 +01:00
Fredrik Burmester
4a37e17324 chore 2025-02-14 13:28:06 +01:00
Fredrik Burmester
52b2a3418e fix: wrong deps 2025-02-13 10:35:58 +01:00
Fredrik Burmester
2753b243e5 chore: remove yarn lock file 2025-02-13 10:33:56 +01:00
Fredrik Burmester
f22b356b7c feat: turkish translations 2025-02-13 10:33:41 +01:00
Fredrik Burmester
d8ba5af8d9 chore: remove old patch 2025-02-13 10:33:34 +01:00
129 changed files with 10261 additions and 13043 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,8 @@ body:
label: Version
description: What version of Streamyfin are you running?
options:
- 0.27.0
- 0.26.1
- 0.26.0
- 0.25.0
- 0.24.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"

5
.gitignore vendored
View File

@@ -10,6 +10,8 @@ npm-debug.*
*.orig.*
web-build/
modules/vlc-player/android/build
modules/vlc-player/android/.gradle
bun.lockb
# macOS
.DS_Store
@@ -41,4 +43,5 @@ credentials.json
.vscode/
.idea/
.ruby-lsp
.ruby-lsp
modules/hls-downloader/android/build

View File

@@ -9,6 +9,7 @@
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"prettier.printWidth": 120,
"[swift]": {
"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`
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`
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`.

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.26.0",
"version": "0.27.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -31,9 +31,11 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 52,
"versionCode": 53,
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png"
"foregroundImage": "./assets/images/adaptive_icon.png",
"backgroundColor": "#464646"
},
"package": "com.fredrikburmester.streamyfin",
"permissions": [
@@ -106,21 +108,11 @@
}
}
],
[
"react-native-bottom-tabs"
],
[
"./plugins/withChangeNativeAndroidTextToWhite.js"
],
[
"./plugins/withAndroidManifest.js"
],
[
"./plugins/withTrustLocalCerts.js"
],
[
"./plugins/withGradleProperties.js"
],
["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
[
"expo-splash-screen",
{

View File

@@ -1,13 +1,18 @@
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 { Platform, TouchableOpacity, View } from "react-native";
import { useTranslation } from "react-i18next";
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() {
const router = useRouter();
const [user] = useAtom(userAtom);
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
@@ -27,13 +32,10 @@ export default function IndexLayout() {
{!Platform.isTV && (
<>
<Chromecast.Chromecast />
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
{user && user.Policy?.IsAdministrator && (
<SessionsButton />
)}
<SettingsButton />
</>
)}
</View>
@@ -52,6 +54,12 @@ export default function IndexLayout() {
title: t("home.downloads.tvseries"),
}}
/>
<Stack.Screen
name="sessions/index"
options={{
title: t("home.sessions.title"),
}}
/>
<Stack.Screen
name="settings"
options={{
@@ -112,3 +120,38 @@ export default function IndexLayout() {
</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 { 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";
import { HomeIndex } from "@/components/settings/HomeIndex";
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 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;
export default function page() {
return <HomeIndex />;
}

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 { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { AudioToggles } from "@/components/settings/AudioToggles";
import DownloadSettings from "@/components/settings/DownloadSettings";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
import { OtherSettings } from "@/components/settings/OtherSettings";
@@ -10,24 +11,24 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic";
import { storage } from "@/utils/mmkv";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import React, { lazy, useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import React, { useEffect } from "react";
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv";
const DownloadSettings = lazy(
() => import("@/components/settings/DownloadSettings")
);
import { useAtom } from "jotai";
import { userAtom } from "@/providers/JellyfinProvider";
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
export default function settings() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success");
@@ -62,6 +63,7 @@ export default function settings() {
>
<View className="p-4 flex flex-col gap-y-4">
<UserInfo />
<QuickConnect className="mb-4" />
<MediaProvider>
@@ -72,12 +74,14 @@ export default function settings() {
<OtherSettings />
{!Platform.isTV && <DownloadSettings />}
<DownloadSettings />
<PluginSettings />
<AppLanguageSelector />
<ChromecastSettings />
<ListGroup title={"Intro"}>
<ListItem
onPress={() => {

View File

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

View File

@@ -42,25 +42,28 @@ const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
const { mediaTitle, releaseYear, posterSrc, ...result } =
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
} & Partial<MovieResult | TvResult>;
mediaType: MediaType;
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const navigation = useNavigation();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -71,7 +74,7 @@ const Page: React.FC = () => {
refetch,
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
queryKey: ["jellyseerr", "detail", mediaType, result.id],
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
@@ -79,7 +82,7 @@ const Page: React.FC = () => {
retryOnMount: true,
refetchInterval: 0,
queryFn: async () => {
return result.mediaType === MediaType.MOVIE
return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!!)
: jellyseerrApi?.tvDetails(result.id!!);
},
@@ -111,10 +114,15 @@ const Page: React.FC = () => {
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const setRequestBody = useCallback((body: MediaRequestBody) => {
_setRequestBody(body)
advancedReqModalRef?.current?.present?.();
}, [requestBody, _setRequestBody, advancedReqModalRef])
const request = useCallback(async () => {
const body: MediaRequestBody = {
mediaId: Number(result.id!!),
mediaType: result.mediaType!!,
mediaType: mediaType!!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
@@ -122,7 +130,7 @@ const Page: React.FC = () => {
};
if (hasAdvancedRequestPermission) {
advancedReqModalRef?.current?.present?.(body);
setRequestBody(body)
return;
}
@@ -132,7 +140,7 @@ const Page: React.FC = () => {
const isAnime = useMemo(
() =>
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
result.mediaType === MediaType.TV,
mediaType === MediaType.TV,
[details]
);
@@ -200,7 +208,7 @@ const Page: React.FC = () => {
<View className="px-4">
<View className="flex flex-row justify-between w-full">
<View className="flex flex-col w-56">
<JellyserrRatings result={result as MovieResult | TvResult} />
<JellyserrRatings result={result as MovieResult | TvResult | MovieDetails | TvDetails} />
<Text
uiTextView
selectable
@@ -247,15 +255,14 @@ const Page: React.FC = () => {
<OverviewText text={result.overview} className="mt-4" />
</View>
{result.mediaType === MediaType.TV && (
{mediaType === MediaType.TV && (
<JellyseerrSeasons
isLoading={isLoading || isFetching}
result={result as TvResult}
details={details as TvDetails}
refetch={refetch}
hasAdvancedRequest={hasAdvancedRequestPermission}
onAdvancedRequest={(data) =>
advancedReqModalRef?.current?.present(data)
setRequestBody(data)
}
/>
)}
@@ -269,14 +276,17 @@ const Page: React.FC = () => {
</ParallaxScrollView>
<RequestModal
ref={advancedReqModalRef}
requestBody={requestBody}
title={mediaTitle}
id={result.id!!}
type={result.mediaType as MediaType}
type={mediaType}
isAnime={isAnime}
onRequested={() => {
_setRequestBody(undefined)
advancedReqModalRef?.current?.close();
refetch();
}}
onDismiss={() => _setRequestBody(undefined)}
/>
<BottomSheetModal
ref={bottomSheetModalRef}

View File

@@ -19,7 +19,7 @@ export default function page() {
const local = useLocalSearchParams();
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const { personId } = local as { personId: string };
@@ -32,15 +32,6 @@ export default function page() {
enabled: !!jellyseerrApi && !!personId,
});
const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
const region = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const castedRoles: PersonCreditCast[] = useMemo(
() =>
uniqBy(orderBy(

View File

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

View File

@@ -38,9 +38,18 @@ export default function SearchLayout() {
}}
/>
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/person/[personId]" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/company/[companyId]" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/genre/[genreId]" options={commonScreenOptions} />
<Stack.Screen
name="jellyseerr/person/[personId]"
options={commonScreenOptions}
/>
<Stack.Screen
name="jellyseerr/company/[companyId]"
options={commonScreenOptions}
/>
<Stack.Screen
name="jellyseerr/genre/[genreId]"
options={commonScreenOptions}
/>
</Stack>
);
}

View File

@@ -26,12 +26,14 @@ import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
import { useTranslation } from "react-i18next";
import { eventBus } from "@/utils/eventBus";
type SearchType = "Library" | "Discover";
@@ -50,7 +52,7 @@ export default function search() {
const { t } = useTranslation();
const { q, prev } = params as { q: string; prev: Href<string> };
const { q } = params as { q: string };
const [searchType, setSearchType] = useState<SearchType>("Library");
const [search, setSearch] = useState<string>("");
@@ -120,22 +122,44 @@ export default function search() {
[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();
useLayoutEffect(() => {
if (Platform.OS === "ios")
navigation.setOptions({
headerSearchBarOptions: {
placeholder: t("search.search"),
onChangeText: (e: any) => {
router.setParams({ q: "" });
setSearch(e.nativeEvent.text);
},
hideWhenScrolling: false,
autoFocus: true,
navigation.setOptions({
headerSearchBarOptions: {
ref: searchBarRef,
placeholder: t("search.search"),
onChangeText: (e: any) => {
router.setParams({ q: "" });
setSearch(e.nativeEvent.text);
},
});
hideWhenScrolling: false,
autoFocus: false,
},
});
}, [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({
queryKey: ["search", "movies", debouncedSearch],
queryFn: () =>
@@ -210,19 +234,12 @@ export default function search() {
paddingRight: insets.right,
}}
>
<View className="flex flex-col">
{Platform.OS === "android" && (
<View className="mb-4 px-4">
<Input
autoCorrect={false}
returnKeyType="done"
keyboardType="web-search"
placeholder={t("search.search_here")}
value={search}
onChangeText={(text) => setSearch(text)}
/>
</View>
)}
<View
className="flex flex-col"
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
>
{jellyseerrApi && (
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<TouchableOpacity onPress={() => setSearchType("Library")}>

View File

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

View File

@@ -3,16 +3,21 @@ import React, { useEffect } from "react";
import { SystemBars } from "react-native-edge-to-edge";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings";
import { Platform } from "react-native";
export default function Layout() {
const [settings] = useSettings();
useEffect(() => {
if (Platform.isTV) return;
if (settings.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
return () => {
if (Platform.isTV) return;
if (settings.autoRotate === true) {
ScreenOrientation.unlockAsync();
} else {
@@ -36,15 +41,6 @@ export default function Layout() {
animation: "fade",
}}
/>
<Stack.Screen
name="transcoding-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack>
</>
);

View File

@@ -5,55 +5,45 @@ import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules/vlc-player";
import { VlcPlayerView } from "@/modules";
import {
PipStartedPayload,
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
// import { useDownload } from "@/providers/DownloadProvider";
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
} from "@/modules/VlcPlayer.types";
const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { getPlaystateApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useMemo,
useRef,
useState,
useEffect,
} from "react";
import {
Alert,
View,
AppState,
AppStateStatus,
Platform,
} from "react-native";
import React, { useCallback, useMemo, useRef, useState, useEffect } from "react";
import { Alert, View, Platform } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
BaseItemDto,
MediaSourceInfo,
PlaybackOrder,
PlaybackProgressInfo,
PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
export default function page() {
console.log("Direct Player");
const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const navigation = useNavigation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
@@ -96,131 +86,115 @@ export default function page() {
offline: string;
}>();
const [settings] = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true";
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
const bitrateValue = bitrateValueStr
? parseInt(bitrateValueStr, 10)
: BITRATES[0].value;
const bitrateValue = bitrateValueStr ? parseInt(bitrateValueStr, 10) : BITRATES[0].value;
const {
data: item,
isLoading: isLoadingItem,
isError: isErrorItem,
} = 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 [item, setItem] = useState<BaseItemDto | null>(null);
const [itemStatus, setItemStatus] = useState({
isLoading: true,
isError: false,
});
const {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
queryFn: async () => {
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return null;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item)
return {
mediaSource: data.mediaSource,
url,
sessionId: undefined,
};
useEffect(() => {
const fetchItemData = async () => {
setItemStatus({ isLoading: true, isError: false });
try {
let fetchedItem: BaseItemDto | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (data) fetchedItem = data.item as BaseItemDto;
} else {
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
fetchedItem = res.data;
}
setItem(fetchedItem);
} catch (error) {
console.error("Failed to fetch item:", error);
setItemStatus({ isLoading: false, isError: true });
} finally {
setItemStatus({ isLoading: false, isError: false });
}
};
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (itemId) {
fetchItemData();
}
}, [itemId, offline, api, user?.Id]);
if (!res) return null;
interface Stream {
mediaSource: MediaSourceInfo;
sessionId: string;
url: string;
}
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
return null;
}
return {
mediaSource,
sessionId,
url,
};
},
enabled: !!itemId && !!item,
staleTime: 0,
const [stream, setStream] = useState<Stream | null>(null);
const [streamStatus, setStreamStatus] = useState({
isLoading: true,
isError: false,
});
const togglePlay = useCallback(async () => {
if (!api) return;
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();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
} else {
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 () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId: mediaSourceId,
@@ -237,27 +211,35 @@ export default function page() {
videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]);
// TODO: unused should remove.
const reportPlaybackStart = useCallback(async () => {
if (offline) return;
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
beforeRemoveListener();
};
}, [navigation, stop]);
if (!stream) return;
await getPlaystateApi(api!).onPlaybackStart({
const currentPlayStateInfo = () => {
return {
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
});
}, [api, item, mediaSourceId, stream]);
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(
async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent;
if (isBuffering) {
setIsBuffering(false);
}
@@ -266,24 +248,30 @@ export default function page() {
if (offline) return;
const currentTimeInTicks = msToTicks(currentTime);
if (!item?.Id || !stream) return;
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(currentTimeInTicks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
reportPlaybackProgress();
},
[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({
isPlaying: isPlaying,
togglePlay: togglePlay,
@@ -291,119 +279,81 @@ export default function page() {
offline,
});
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted)
}, [])
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
return;
}
if (state === "Paused") {
setIsPlaying(false);
return;
}
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
}, []);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item]);
const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
// Handle app going to the background
if (nextAppState.match(/inactive|background/)) {
_setShowControls(false)
const onPlaybackStateChanged = useCallback(
async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
reportPlaybackProgress();
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
setAppState(nextAppState);
};
// Use AppState.addEventListener and return a cleanup function
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
if (state === "Paused") {
setIsPlaying(false);
reportPlaybackProgress();
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
return () => {
// Cleanup the event listener when the component is unmounted
subscription.remove();
};
}, [appState, isPipStarted, isPlaying]);
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
},
[reportPlaybackProgress]
);
// Preselection of audio and subtitle tracks.
if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
let externalTrack = { name: "", DeliveryUrl: "" };
const allAudio = stream?.mediaSource.MediaStreams?.filter((audio) => audio.Type === "Audio") || [];
// Move all the external subtitles last, because vlc places them last.
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub: { Type: string }) => sub.Type === "Subtitle"
stream?.mediaSource.MediaStreams?.filter((sub) => sub.Type === "Subtitle").sort(
(a, b) => Number(a.IsExternal) - Number(b.IsExternal)
) || [];
const chosenSubtitleTrack = allSubs.find(
(sub: { Index: number }) => sub.Index === subtitleIndex
);
const allAudio =
stream?.mediaSource.MediaStreams?.filter(
(audio: { Type: string }) => audio.Type === "Audio"
) || [];
const chosenAudioTrack = allAudio.find(
(audio: { Index: number | undefined }) => audio.Index === audioIndex
);
// Direct playback CASE
if (!bitrateValue) {
// If Subtitle is embedded we can use the position to select it straight away.
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
// If Subtitle is external we need to pass the URL to the player.
externalTrack = {
name: chosenSubtitleTrack.DisplayTitle || "",
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
};
}
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
if (chosenAudioTrack)
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} else {
// Transcoded playback CASE
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
externalTrack = {
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
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}`);
}
const insets = useSafeAreaInsets();
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
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 (isErrorItem || isErrorStreamUrl)
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>
@@ -427,11 +377,11 @@ export default function page() {
<VlcPlayerView
ref={videoRef}
source={{
uri: stream.url,
uri: stream?.url || "",
autoplay: true,
isNetwork: true,
startPosition,
externalTrack,
externalSubtitles,
initOptions,
}}
style={{ width: "100%", height: "100%" }}
@@ -439,21 +389,17 @@ export default function page() {
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted}
onVideoLoadStart={() => {}}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
}}
onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video")
);
Alert.alert(t("player.error"), t("player.an_error_occured_while_playing_the_video"));
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
/>
</View>
{videoRef.current && (
{videoRef.current && !isPipStarted && isMounted === true ? (
<Controls
mediaSource={stream?.mediaSource}
item={item}
@@ -480,10 +426,9 @@ export default function page() {
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}
stop={stop}
isVlc
/>
)}
) : null}
</View>
);
}
}

View File

@@ -1,546 +0,0 @@
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { TrackInfo } from "@/modules/vlc-player";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import transcoding from "@/utils/profiles/transcoding";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, {
OnProgressData,
SelectedTrack,
SelectedTrackType,
VideoRef,
} from "react-native-video";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next";
const Player = () => {
console.log("Transcoding Player");
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const { t } = useTranslation();
const firstTime = useRef(true);
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
lightHapticFeedback();
}, []);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const {
itemId,
audioIndex: audioIndexStr,
subtitleIndex: subtitleIndexStr,
mediaSourceId,
bitrateValue: bitrateValueStr,
} = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr
? parseInt(subtitleIndexStr, 10)
: undefined;
const bitrateValue = bitrateValueStr
? parseInt(bitrateValueStr, 10)
: undefined;
const {
data: item,
isLoading: isLoadingItem,
isError: isErrorItem,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (!api) {
throw new Error("No api");
}
if (!itemId) {
console.warn("No itemId");
return null;
}
const res = await getUserLibraryApi(api).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
staleTime: 0,
});
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
const {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
queryFn: async () => {
if (!api) {
throw new Error("No api");
}
if (!item) {
console.warn("No item", itemId, item);
return null;
}
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: transcoding,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
console.warn("No sessionId or mediaSource or url", url);
return null;
}
return {
mediaSource,
sessionId,
url,
};
},
enabled: !!item,
staleTime: 0,
});
const poster = usePoster(item, api);
const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback(async () => {
lightHapticFeedback();
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
isPaused: true,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
isPaused: false,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
}
}, [
isPlaying,
api,
item,
videoRef,
settings,
stream,
audioIndex,
subtitleIndex,
mediaSourceId,
]);
const play = useCallback(() => {
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
videoRef.current?.pause();
}, [videoRef]);
const seek = useCallback(
(seconds: number) => {
videoRef.current?.seek(seconds);
},
[videoRef]
);
const reportPlaybackStopped = async () => {
if (!item?.Id) return;
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item.Id,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
playSessionId: stream?.sessionId,
});
revalidateProgressCache();
};
const stop = useCallback(() => {
reportPlaybackStopped();
videoRef.current?.pause();
setIsPlaybackStopped(true);
}, [videoRef, reportPlaybackStopped]);
const reportPlaybackStart = async () => {
if (!item?.Id) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
};
const onProgress = useCallback(
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const ticks = secondsToTicks(data.currentTime);
progress.value = ticks;
cacheProgress.value = secondsToTicks(data.playableDuration);
// TODO: Use this when streaming with HLS url, but NOT when direct playing
// TODO: since playable duration is always 0 then.
setIsBuffering(data.playableDuration === 0);
if (!item?.Id || data.currentTime === 0) {
return;
}
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
},
[
item,
isPlaying,
api,
isPlaybackStopped,
isSeeking,
stream,
mediaSourceId,
audioIndex,
subtitleIndex,
]
);
useWebSocket({
isPlaying: isPlaying,
togglePlay: togglePlay,
stopPlayback: stop,
offline: false,
});
const [selectedTextTrack, setSelectedTextTrack] = useState<
SelectedTrack | undefined
>();
const [embededTextTracks, setEmbededTextTracks] = useState<
{
index: number;
language?: string | undefined;
selected?: boolean | undefined;
title?: string | undefined;
type: any;
}[]
>([]);
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
SelectedTrack | undefined
>(undefined);
useEffect(() => {
if (selectedTextTrack === undefined) {
const subtitleHelper = new SubtitleHelper(
stream?.mediaSource.MediaStreams ?? []
);
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
subtitleIndex!
);
// Most likely the subtitle is burned in.
if (embeddedTrackIndex === -1) return;
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: embeddedTrackIndex,
});
}
}, [embededTextTracks]);
const getAudioTracks = (): TrackInfo[] => {
return audioTracks.map((t) => ({
name: t.name,
index: t.index,
}));
};
const getSubtitleTracks = (): TrackInfo[] => {
return embededTextTracks.map((t) => ({
name: t.title ?? "",
index: t.index,
language: t.language,
}));
};
useFocusEffect(
React.useCallback(() => {
return async () => {
stop();
};
}, [])
);
if (isLoadingItem || isLoadingStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
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 (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
}}
>
{videoSource ? (
<>
<Video
ref={videoRef}
source={videoSource}
style={{
height: "100%",
width: "100%",
}}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={(e) => {
console.error("Error playing video", e);
}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
onTextTracks={(data) => {
setEmbededTextTracks(data.textTracks as any);
}}
onBuffer={(e) => {
setIsBuffering(e.isBuffering);
}}
onAudioTracks={(e) => {
setAudioTracks(
e.audioTracks.map((t) => ({
index: t.index,
name: t.title ?? "",
language: t.language,
}))
);
}}
selectedTextTrack={selectedTextTrack}
selectedAudioTrack={selectedAudioTrack}
/>
</>
) : (
<Text>{t("player.no_video_source")}</Text>
)}
</View>
{item && (
<Controls
mediaSource={stream?.mediaSource}
videoRef={videoRef}
enableTrickplay={true}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
seek={seek}
play={play}
pause={pause}
stop={stop}
getSubtitleTracks={getSubtitleTracks}
setSubtitleTrack={(i) => {
if (i === -1) {
setSelectedTextTrack({
type: SelectedTrackType.DISABLED,
value: undefined,
});
return;
}
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: i,
});
}}
getAudioTracks={getAudioTracks}
setAudioTrack={(i) => {
setSelectedAudioTrack({
type: SelectedTrackType.INDEX,
value: i,
});
}}
/>
)}
</View>
);
};
export function usePoster(
item: BaseItemDto | null | undefined,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}
export function useVideoSource(
item: BaseItemDto | null | undefined,
api: Api | null,
poster: string | undefined,
url?: string | null
) {
const videoSource = useMemo(() => {
if (!item || !api || !url) {
return null;
}
const startPosition = item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
title: item?.Name || "Unknown",
description: item?.Overview ?? undefined,
imageUri: poster,
subtitle: item?.Album ?? undefined,
},
};
}, [item, api, poster, url]);
return videoSource;
}
export default Player;

View File

@@ -1,6 +1,5 @@
import "@/augmentations";
import { Platform } from "react-native";
import { Text } from "@/components/common/Text";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
@@ -10,10 +9,6 @@ import {
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import {
SplashScreenProvider,
useSplashScreenLoading,
} from "@/providers/SplashScreenProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
@@ -32,16 +27,15 @@ const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch")
: null;
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;
import { router, Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { getLocales } from "expo-localization";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef } from "react";
import { I18nextProvider, useTranslation } from "react-i18next";
import { I18nextProvider } from "react-i18next";
import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
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() {
if (Platform.isTV) return;
@@ -224,17 +227,15 @@ export default function RootLayout() {
Appearance.setColorScheme("dark");
return (
<SplashScreenProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider>
<ActionSheetProvider>
<I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</ActionSheetProvider>
</JotaiProvider>
</GestureHandlerRootView>
</SplashScreenProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider>
<ActionSheetProvider>
<I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</ActionSheetProvider>
</JotaiProvider>
</GestureHandlerRootView>
);
}
@@ -261,17 +262,15 @@ function Layout() {
}, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) {
useKeepAwake();
useNotificationObserver();
const { i18n } = useTranslation();
useEffect(() => {
checkAndRequestPermissions();
}, []);
useEffect(() => {
// If the user has auto rotate enabled, unlock the orientation
if (Platform.isTV) return;
if (settings.autoRotate === true) {
ScreenOrientation.unlockAsync();
} else {
@@ -303,16 +302,6 @@ function Layout() {
}, []);
}
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
useSplashScreenLoading(!loaded);
if (!loaded) {
return null;
}
return (
<QueryClientProvider client={queryClient}>
<JobQueueProvider>
@@ -324,7 +313,7 @@ function Layout() {
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack>
<Stack initialRouteName="(auth)/(tabs)">
<Stack.Screen
name="(auth)/(tabs)"
options={{

View File

@@ -9,7 +9,7 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import React, { useCallback, useEffect, useState } from "react";
import {
Alert,
@@ -19,17 +19,20 @@ import {
TouchableOpacity,
View,
} from "react-native";
import { Keyboard } from "react-native";
import { z } from "zod";
import { t } from 'i18next';
import { t } from "i18next";
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 } =
useJellyfin();
const [api] = useAtom(apiAtom);
const params = useLocalSearchParams();
const {
apiUrl: _apiUrl,
@@ -37,6 +40,8 @@ const CredentialsSchema = z.object({
password: _password,
} = 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 [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{
@@ -47,10 +52,11 @@ const CredentialsSchema = z.object({
password: _password,
});
/**
* A way to auto login based on a link
*/
useEffect(() => {
(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) {
setServer({
address: _apiUrl,
@@ -66,7 +72,6 @@ const CredentialsSchema = z.object({
})();
}, [_apiUrl, _username, _password]);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
@@ -79,15 +84,17 @@ const CredentialsSchema = z.object({
className="flex flex-row items-center"
>
<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>
) : null,
});
}, [serverName, navigation, api?.basePath]);
const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => {
Keyboard.dismiss();
setLoading(true);
try {
const result = CredentialsSchema.safeParse(credentials);
@@ -98,15 +105,16 @@ const CredentialsSchema = z.object({
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
} 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 {
setLoading(false);
}
};
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
/**
* Checks the availability and validity of a Jellyfin server URL.
*
@@ -180,14 +188,21 @@ const CredentialsSchema = z.object({
try {
const code = await initiateQuickConnect();
if (code) {
Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
{
text: t("login.got_it"),
},
]);
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[
{
text: t("login.got_it"),
},
]
);
}
} 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="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold -mb-2">
<>
{serverName ? (
<>
{t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : t("login.login_title")}
</>
</Text>
<Text className="text-2xl font-bold -mb-2">
<>
{serverName ? (
<>
{t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</>
</Text>
<Text className="text-xs text-neutral-400">
{api.basePath}
</Text>
@@ -220,7 +237,6 @@ const CredentialsSchema = z.object({
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
@@ -300,7 +316,9 @@ const CredentialsSchema = z.object({
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
onPress={async () => {
await handleConnect(serverURL);
}}
className="w-full grow"
>
{t("server.connect_button")}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 79 KiB

2929
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

View File

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

View File

@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
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 { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
@@ -21,7 +21,7 @@ import {
import { Href, router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai";
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 { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
@@ -66,10 +66,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(settings?.defaultBitrate ?? {
key: "Max",
value: undefined,
});
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
settings?.defaultBitrate ?? {
key: "Max",
value: undefined,
}
);
const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading,
@@ -162,7 +164,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
);
}
} 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,
@@ -333,7 +337,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
{title}
</Text>
<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>
</View>
<View className="flex flex-col space-y-2 w-full items-start">
@@ -391,12 +398,16 @@ export const DownloadSingleItem: React.FC<{
size?: "default" | "large";
item: BaseItemDto;
}> = ({ item, size = "default" }) => {
if (Platform.isTV) return;
return (
<DownloadItems
size={size}
title={item.Type == "Episode"
? t("item_card.download.download_episode")
: t("item_card.download.download_movie")}
title={
item.Type == "Episode"
? t("item_card.download.download_episode")
: t("item_card.download.download_movie")
}
subtitle={item.Name!}
items={[item]}
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;
return (
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
{tags.map((tag, idx) => (
<View key={idx}>
<Tag key={idx} textClass={textClass} text={tag}/>
<Tag key={idx} textClass={textClass} text={tag} {...tagProps}/>
</View>
))}
</View>

View File

@@ -15,8 +15,8 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import {
@@ -25,17 +25,16 @@ import {
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react";
import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
import { AddToFavorites } from "./AddToFavorites";
import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { AddToFavorites } from "./AddToFavorites";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
export type SelectedOptions = {
bitrate: Bitrate;
@@ -95,9 +94,11 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
/>
{item.Type !== "Program" && (
<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" />
<AddToFavorites item={item} type="item" />
<AddToFavorites item={item} />
</View>
)}
</View>
@@ -118,37 +119,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
const [isTranscoding, setIsTranscoding] = useState(false);
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
useState<number | undefined>(selectedOptions?.subtitleIndex);
useEffect(() => {
const isTranscoding = Boolean(selectedOptions?.bitrate.value);
if (isTranscoding) {
setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
const subHelper = new SubtitleHelper(
selectedOptions?.mediaSource?.MediaStreams ?? []
);
const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
selectedOptions?.subtitleIndex
);
setSelectedOptions((prev) => ({
...prev!,
subtitleIndex: newSubtitleIndex ?? -1,
}));
}
if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
setSelectedOptions((prev) => ({
...prev!,
subtitleIndex: previouslyChosenSubtitleIndex,
}));
}
setIsTranscoding(isTranscoding);
}, [selectedOptions?.bitrate]);
if (!selectedOptions) return null;
return (
@@ -196,7 +166,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
}
>
<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">
<ItemHeader item={item} className="mb-4" />
{item.Type !== "Program" && !Platform.isTV && (
@@ -239,7 +208,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
selected={selectedOptions.audioIndex}
/>
<SubtitleTrackSelector
isTranscoding={isTranscoding}
source={selectedOptions.mediaSource}
onChange={(val) =>
setSelectedOptions(
@@ -255,13 +223,11 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View>
)}
{/* {!Platform.isTV && ( */}
<PlayButton
className="grow"
selectedOptions={selectedOptions}
item={item}
/>
{/* )} */}
</View>
{item.Type === "Episode" && (

View File

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

View File

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

View File

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

View File

@@ -4,40 +4,31 @@ import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | undefined;
isTranscoding?: boolean;
}
export const SubtitleTrackSelector: React.FC<Props> = ({
source,
onChange,
selected,
isTranscoding,
...props
}) => {
if (Platform.isTV) return null;
const subtitleStreams = useMemo(() => {
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
if (isTranscoding && Platform.OS === "ios") {
return subtitleHelper.getUniqueSubtitles();
}
return subtitleHelper.getSubtitles();
}, [source, isTranscoding]);
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]);
const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected),
() => subtitleStreams?.find((x) => x.Index === selected),
[subtitleStreams, selected]
);
if (subtitleStreams.length === 0) return null;
if (subtitleStreams?.length === 0) return null;
const { t } = useTranslation();
@@ -52,7 +43,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<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")}
</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">

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,15 @@
import { Text } from "@/components/common/Text";
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 { formatTimeString } from "@/utils/time";
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 { Image } from "expo-image";
import { useRouter } from "expo-router";
const FFmpegKitProvider = !Platform.isTV ? require("ffmpeg-kit-react-native") : null;
import { useAtom } from "jotai";
import { t } from "i18next";
import { useMemo } from "react";
import {
ActivityIndicator,
Platform,
@@ -21,10 +20,12 @@ import {
} from "react-native";
import { toast } from "sonner-native";
import { Button } from "../Button";
import { Image } from "expo-image";
import { useMemo } from "react";
import { storage } from "@/utils/mmkv";
import { t } from "i18next";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
const FFmpegKitProvider = !Platform.isTV
? require("ffmpeg-kit-react-native")
: null;
interface Props extends ViewProps {}
@@ -33,14 +34,20 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
if (processes?.length === 0)
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">{t("home.downloads.active_download")}</Text>
<Text className="opacity-50">{t("home.downloads.no_active_downloads")}</Text>
<Text className="text-lg font-bold">
{t("home.downloads.active_download")}
</Text>
<Text className="opacity-50">
{t("home.downloads.no_active_downloads")}
</Text>
</View>
);
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold mb-2">{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">
{processes?.map((p: JobStatus) => (
<DownloadCard key={p.item.Id} process={p} />
@@ -81,7 +88,9 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}
} else {
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: () => {
@@ -156,7 +165,9 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
)}
{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 File

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

View File

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

View File

@@ -15,18 +15,22 @@ import { useTranslation } from "react-i18next";
interface Props {
id: number;
title: string,
requestBody?: MediaRequestBody,
type: MediaType;
isAnime?: boolean;
is4k?: boolean;
onRequested?: () => void;
onDismiss?: () => void;
}
const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps, 'id'>>(({
id,
title,
requestBody,
type,
isAnime = false,
onRequested,
onDismiss,
...props
}, ref) => {
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr();
@@ -39,8 +43,6 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
const { t } = useTranslation();
const [modalRequestProps, setModalRequestProps] = useState<MediaRequestBody>();
const {data: serviceSettings} = useQuery({
queryKey: ["jellyseerr", "request", type, 'service'],
queryFn: async () => jellyseerrApi?.service(type == 'movie' ? 'radarr' : 'sonarr'),
@@ -98,16 +100,19 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
: defaultServiceDetails?.server.activeTags
)?.includes(t.id)
) ?? []
console.log(tags)
return tags
},
[defaultServiceDetails]
);
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(
@@ -117,12 +122,12 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
profileId: defaultProfile.id,
rootFolder: defaultFolder.path,
tags: defaultTags.map(t => t.id),
...modalRequestProps,
...requestBody,
...requestOverrides
},
onRequested
)
}, [modalRequestProps, requestOverrides, defaultProfile, defaultFolder, defaultTags]);
}, [requestBody, requestOverrides, defaultProfile, defaultFolder, defaultTags]);
const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`;
@@ -131,7 +136,7 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
ref={ref}
enableDynamicSizing
enableDismissOnClose
onDismiss={() => setModalRequestProps(undefined)}
onDismiss={onDismiss}
handleIndicatorStyle={{
backgroundColor: "white",
}}
@@ -146,89 +151,86 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
/>
}
>
{(data) => {
setModalRequestProps(data?.data as MediaRequestBody)
return <BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">{t("jellyseerr.advanced")}</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>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">{t("jellyseerr.advanced")}</Text>
{seasonTitle &&
<Text className="text-neutral-300">{seasonTitle}</Text>
}
</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>
);
});

View File

@@ -8,6 +8,7 @@ import {View} from "react-native";
import {networks} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import {studios} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
import RecentRequestsSlide from "@/components/jellyseerr/discover/RecentRequestsSlide";
interface Props {
sliders?: DiscoverSlider[];
@@ -25,6 +26,8 @@ const Discover: React.FC<Props> = ({ sliders }) => {
<View className="flex flex-col space-y-4 mb-8">
{sortedSliders.map(slide => {
switch (slide.type) {
case DiscoverSliderType.RECENT_REQUESTS:
return <RecentRequestsSlide key={slide.id} slide={slide} />
case DiscoverSliderType.NETWORKS:
return <CompanySlide key={slide.id} slide={slide} data={networks}/>
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 Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
import {ViewProps} from "react-native";
import {uniqBy} from "lodash";
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const { jellyseerrApi } = useJellyseerr();
@@ -57,7 +58,11 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) =>
});
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]
);
@@ -74,7 +79,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) =>
fetchNextPage()
}}
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>
<View
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) => {
if (isValidElement<{ style?: ViewStyle }>(child)) {

View File

@@ -36,7 +36,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity
disabled={disabled}
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" : ""
}`}
{...props}
@@ -55,7 +55,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
);
return (
<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" : ""
}`}
{...props}

View File

@@ -1,28 +1,42 @@
import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
import { Text } from "@/components/common/Text";
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
import {Text} from "@/components/common/Text";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { Image } from "expo-image";
import { useMemo } from "react";
import { View, ViewProps } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {useJellyseerrCanRequest} from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {Image} from "expo-image";
import {useMemo} from "react";
import {View, ViewProps} from "react-native";
import Animated, {useAnimatedStyle, useSharedValue, withTiming,} from "react-native-reanimated";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import type {DownloadingItem} from "@/utils/jellyseerr/server/lib/downloadtracker";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
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 {
item: MovieResult | TvResult;
item: MovieResult | TvResult | MovieDetails | TvDetails;
horizontal?: boolean;
showDownloadInfo?: boolean;
mediaRequest?: MediaRequest;
}
const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
const { jellyseerrApi } = useJellyseerr();
const JellyseerrPoster: React.FC<Props> = ({
item,
horizontal,
showDownloadInfo,
mediaRequest,
...props
}) => {
const { jellyseerrApi, getTitle, getYear, getMediaType, isJellyseerrResult } = useJellyseerr();
const loadingOpacity = useSharedValue(1);
const imageOpacity = useSharedValue(0);
const {t} = useTranslation();
const loadingAnimatedStyle = useAnimatedStyle(() => ({
opacity: loadingOpacity.value,
@@ -38,27 +52,64 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
};
const imageSrc = useMemo(
() => jellyseerrApi?.imageProxy(item.posterPath, "w300_and_h450_face"),
[item, jellyseerrApi]
() => jellyseerrApi?.imageProxy(
horizontal ? item.backdropPath : item.posterPath,
horizontal ? "w1920_and_h800_multi_faces" : "w300_and_h450_face"
),
[item, jellyseerrApi, horizontal]
);
const title = useMemo(
() => (item.mediaType === MediaType.MOVIE ? item.title : item.name),
[item]
);
const title = useMemo(() => getTitle(item), [item]);
const releaseYear = useMemo(() => getYear(item), [item]);
const mediaType = useMemo(() => getMediaType(item), [item]);
const releaseYear = useMemo(
() =>
new Date(
item.mediaType === MediaType.MOVIE
? item.releaseDate
: item.firstAirDate
).getFullYear(),
[item]
);
const size = useMemo(() => horizontal ? 'h-28' : 'w-28', [horizontal])
const ratio = useMemo(() => horizontal ? '15/10' : '10/15', [horizontal])
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 (
<TouchableJellyseerrRouter
result={item}
@@ -66,9 +117,10 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
releaseYear={releaseYear}
canRequest={canRequest}
posterSrc={imageSrc!!}
mediaType={mediaType}
>
<View className="flex flex-col w-28 mr-2">
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
<View className={`flex flex-col mr-2 h-auto`}>
<View className={`relative rounded-lg overflow-hidden border border-neutral-900 ${size} aspect-[${ratio}]`}>
<Animated.View style={imageAnimatedStyle}>
<Image
key={item.id}
@@ -77,26 +129,65 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
width: "100%",
aspectRatio: ratio,
[horizontal ? 'height' : 'width']: "100%"
}}
onLoad={handleImageLoad}
/>
</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
className="absolute bottom-1 right-1"
showRequestIcon={canRequest}
mediaStatus={item?.mediaInfo?.status}
mediaStatus={mediaRequest?.media?.status || item?.mediaInfo?.status}
/>
<JellyseerrMediaIcon
className="absolute top-1 left-1"
mediaType={item?.mediaType}
mediaType={mediaType}
/>
</View>
<View className="mt-2 flex flex-col">
<Text numberOfLines={2}>{title}</Text>
<Text className="text-xs opacity-50 align-bottom">{releaseYear}</Text>
</View>
</View>
<View className={`mt-2 flex flex-col ${horizontal ? 'w-44' : 'w-28'}`}>
<Text numberOfLines={2}>{title}</Text>
<Text className="text-xs opacity-50 align-bottom">{releaseYear}</Text>
</View>
</TouchableJellyseerrRouter>
);

View File

@@ -23,6 +23,8 @@ import { Loader } from "../Loader";
import { t } from "i18next";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
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<{
details: TvDetails;
@@ -52,26 +54,51 @@ const JellyseerrSeasonEpisodes: React.FC<{
};
const RenderItem = ({ item, index }: any) => {
const { jellyseerrApi } = useJellyseerr();
const { jellyseerrApi, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
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 (
<View className="flex flex-col w-44 mt-2">
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
{!imageError ? (
<Image
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
onError={(e) => {
setImageError(true);
}}
/>
<>
<Image
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
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">
<Ionicons
@@ -101,14 +128,12 @@ const RenderItem = ({ item, index }: any) => {
const JellyseerrSeasons: React.FC<{
isLoading: boolean;
result?: TvResult;
details?: TvDetails;
hasAdvancedRequest?: boolean,
onAdvancedRequest?: (data: MediaRequestBody) => void;
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
}> = ({
isLoading,
result,
details,
refetch,
hasAdvancedRequest,
@@ -168,7 +193,7 @@ const JellyseerrSeasons: React.FC<{
return onAdvancedRequest?.(body)
}
requestMedia(result?.name!!, body, refetch);
requestMedia(details.name, body, refetch);
}
}, [jellyseerrApi, seasons, details, hasAdvancedRequest, onAdvancedRequest]);
@@ -200,7 +225,7 @@ const JellyseerrSeasons: React.FC<{
return onAdvancedRequest?.(body)
}
requestMedia(`${result?.name!!}, Season ${seasonNumber}`, body, refetch);
requestMedia(`${details.name}, Season ${seasonNumber}`, body, refetch);
}
}, [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}
>
<DropdownMenu.Label>
{t("home.settings.downloads.methods")}
{t("home.settings.downloads.download_method")}
</DropdownMenu.Label>
<DropdownMenu.Item
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 [settings, updateSettings, pluginSettings] = useSettings();
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
useState<boolean>(false);
const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined
>(undefined);
@@ -39,11 +36,16 @@ export const JellyseerrSettings = () => {
const loginToJellyseerrMutation = useMutation({
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");
}
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
return jellyseerrTempApi.login(user.Name, jellyseerrPassword);
const jellyseerrTempApi = new JellyseerrApi(
jellyseerrServerUrl || settings.jellyseerrServerUrl || ""
);
const testResult = await jellyseerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
},
onSuccess: (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 = () => {
clearAllJellyseerData().finally(() => {
setJellyseerrUser(undefined);
setJellyseerrPassword(undefined);
setjellyseerrServerUrl(undefined);
setPromptForJellyseerrPass(false);
});
};
@@ -92,34 +74,46 @@ export const JellyseerrSettings = () => {
<>
<ListGroup title={"Jellyseerr"}>
<ListItem
title={t("home.settings.plugins.jellyseerr.total_media_requests")}
title={t(
"home.settings.plugins.jellyseerr.total_media_requests"
)}
value={jellyseerrUser?.requestCount?.toString()}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
value={
jellyseerrUser?.movieQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
jellyseerrUser?.movieQuotaLimit?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
value={
jellyseerrUser?.movieQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
jellyseerrUser?.movieQuotaDays?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
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
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>
<View className="p-4">
<Button color="red" onPress={clearData}>
{t("home.settings.plugins.jellyseerr.reset_jellyseerr_config_button")}
{t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button"
)}
</Button>
</View>
</>
@@ -128,15 +122,20 @@ export const JellyseerrSettings = () => {
<Text className="text-xs text-red-600 mb-2">
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</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">
<Text className="text-xs text-gray-600">
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
</View>
<Input
placeholder={t("home.settings.plugins.jellyseerr.server_url_placeholder")}
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
className="border border-neutral-800 mb-2"
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder"
)}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
@@ -145,40 +144,20 @@ export const JellyseerrSettings = () => {
autoCapitalize="none"
textContentType="URL"
onChangeText={setjellyseerrServerUrl}
editable={!testJellyseerrServerUrlMutation.isPending}
editable={!loginToJellyseerrMutation.isPending}
/>
<Button
loading={testJellyseerrServerUrlMutation.isPending}
disabled={testJellyseerrServerUrlMutation.isPending}
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>
<View>
<Text className="font-bold mb-2">
{t("home.settings.plugins.jellyseerr.password")}
</Text>
<Input
className="border border-neutral-800"
autoFocus={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}
keyboardType="default"
secureTextEntry={true}
@@ -186,10 +165,7 @@ export const JellyseerrSettings = () => {
autoCapitalize="none"
textContentType="password"
onChangeText={setJellyseerrPassword}
editable={
!loginToJellyseerrMutation.isPending &&
promptForJellyseerrPass
}
editable={!loginToJellyseerrMutation.isPending}
/>
<Button
loading={loginToJellyseerrMutation.isPending}

View File

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

View File

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

View File

@@ -1,61 +1,40 @@
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import {
TrackInfo,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
getDefaultPlaySettings,
previousIndexes,
} from "@/utils/jellyfin/getDefaultPlaySettings";
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
import { writeToLog } from "@/utils/log";
import {
formatTimeString,
msToTicks,
secondsToMs,
ticksToMs,
ticksToSeconds,
} from "@/utils/time";
import {Text} from "@/components/common/Text";
import {Loader} from "@/components/Loader";
import {useAdjacentItems} from "@/hooks/useAdjacentEpisodes";
import {useCreditSkipper} from "@/hooks/useCreditSkipper";
import {useHaptic} from "@/hooks/useHaptic";
import {useIntroSkipper} from "@/hooks/useIntroSkipper";
import {useTrickplay} from "@/hooks/useTrickplay";
import {TrackInfo, VlcPlayerViewRef,} from "@/modules/VlcPlayer.types";
import {apiAtom} from "@/providers/JellyfinProvider";
import {useSettings, VideoPlayer} from "@/utils/atoms/settings";
import {getDefaultPlaySettings,} from "@/utils/jellyfin/getDefaultPlaySettings";
import {getItemById} from "@/utils/jellyfin/user-library/getItemById";
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 {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 { useAtom } from "jotai";
import { debounce } from "lodash";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {Platform, TouchableOpacity, useWindowDimensions, View} 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 {useAtom} from "jotai";
import {debounce} from "lodash";
import React, {useCallback, useEffect, useRef, useState} from "react";
import {Platform, TouchableOpacity, useWindowDimensions, View,} 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 BrightnessSlider from "./BrightnessSlider";
import { ControlProvider } from "./contexts/ControlContext";
import { VideoProvider } from "./contexts/VideoContext";
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import { EpisodeList } from "./EpisodeList";
import {ControlProvider} from "./contexts/ControlContext";
import {VideoProvider} from "./contexts/VideoContext";
import DropdownView from "./dropdown/DropdownView";
import {EpisodeList} from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { useControlsTimeout } from "./useControlsTimeout";
import { VideoTouchOverlay } from "./VideoTouchOverlay";
import {useControlsTimeout} from "./useControlsTimeout";
import {VideoTouchOverlay} from "./VideoTouchOverlay";
interface Props {
item: BaseItemDto;
@@ -83,40 +62,38 @@ interface Props {
setSubtitleURL?: (url: string, customName: string) => void;
setSubtitleTrack?: (index: number) => void;
setAudioTrack?: (index: number) => void;
stop: (() => Promise<void>) | (() => void);
isVlc?: boolean;
}
const CONTROLS_TIMEOUT = 4000;
export const Controls: React.FC<Props> = ({
item,
seek,
startPictureInPicture,
play,
pause,
togglePlay,
isPlaying,
isSeeking,
progress,
isBuffering,
cacheProgress,
showControls,
setShowControls,
ignoreSafeAreas,
setIgnoreSafeAreas,
mediaSource,
isVideoLoaded,
getAudioTracks,
getSubtitleTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
stop,
offline = false,
enableTrickplay = true,
isVlc = false,
}) => {
item,
seek,
startPictureInPicture,
play,
pause,
togglePlay,
isPlaying,
isSeeking,
progress,
isBuffering,
cacheProgress,
showControls,
setShowControls,
ignoreSafeAreas,
setIgnoreSafeAreas,
mediaSource,
isVideoLoaded,
getAudioTracks,
getSubtitleTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
offline = false,
enableTrickplay = true,
isVlc = false,
}) => {
const [settings] = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
@@ -185,85 +162,60 @@ export const Controls: React.FC<Props> = ({
isVlc
);
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
if (!item || !settings) return;
lightHapticFeedback();
lightHapticFeedback();
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
};
const 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 {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
item,
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();
const queryParams = new URLSearchParams({
itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue.toString(),
}).toString();
stop()
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
}, [previousItem, settings, subtitleIndex, audioIndex]);
},
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router]
);
const goToPreviousItem = useCallback(() => {
if (!previousItem) return;
goToItemCommon(previousItem);
}, [previousItem, goToItemCommon]);
const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return;
if (!nextItem) return;
goToItemCommon(nextItem);
}, [nextItem, goToItemCommon]);
lightHapticFeedback();
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
};
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()
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
}, [nextItem, settings, subtitleIndex, audioIndex]);
const goToItem = useCallback(
async (itemId: string) => {
const gotoItem = await getItemById(api, itemId);
if (!gotoItem) return;
goToItemCommon(gotoItem);
},
[goToItemCommon, api]
);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
@@ -387,54 +339,6 @@ export const Controls: React.FC<Props> = ({
}
}, [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()
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
} catch (error) {
console.error("Error in gotoEpisode:", error);
}
},
[settings, subtitleIndex, audioIndex]
);
const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev);
lightHapticFeedback();
@@ -508,7 +412,6 @@ export const Controls: React.FC<Props> = ({
}, [trickPlayUrl, trickplayInfo, time]);
const onClose = async () => {
stop()
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
@@ -551,26 +454,25 @@ export const Controls: React.FC<Props> = ({
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row w-full pt-2`}
>
<View className="mr-auto">
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
{!mediaSource?.TranscodingUrl ? (
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
</VideoProvider>
</View>
{!Platform.isTV && (
<View className="mr-auto">
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
<DropdownView />
</VideoProvider>
</View>
)}
<View className="flex flex-row items-center space-x-2 ">
{!Platform.isTV && (
{!Platform.isTV && settings.defaultPlayer == VideoPlayer.VLC_4 && (
<TouchableOpacity
onPress={startPictureInPicture}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<MaterialIcons
name="picture-in-picture"
@@ -802,8 +704,8 @@ export const Controls: React.FC<Props> = ({
!nextItem
? false
: isVlc
? remainingTime < 10000
: remainingTime < 10
? remainingTime < 10000
: remainingTime < 10
}
onFinish={goToNextItem}
onPress={goToNextItem}

View File

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

View File

@@ -1,20 +1,12 @@
import { TrackInfo } from "@/modules/vlc-player";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import React, {
createContext,
useContext,
useState,
ReactNode,
useEffect,
} from "react";
import { TrackInfo } from "@/modules/VlcPlayer.types";
import React, { createContext, useContext, useState, ReactNode, useEffect, useMemo } from "react";
import { useControlContext } from "./ControlContext";
import { Track } from "../types";
import { router, useLocalSearchParams } from "expo-router";
interface VideoContextProps {
audioTracks: TrackInfo[] | null;
subtitleTracks: TrackInfo[] | null;
audioTracks: Track[] | null;
subtitleTracks: Track[] | null;
setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
@@ -24,14 +16,8 @@ const VideoContext = createContext<VideoContextProps | undefined>(undefined);
interface VideoProviderProps {
children: ReactNode;
getAudioTracks:
| (() => Promise<TrackInfo[] | null>)
| (() => TrackInfo[])
| undefined;
getSubtitleTracks:
| (() => Promise<TrackInfo[] | null>)
| (() => TrackInfo[])
| undefined;
getAudioTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined;
getSubtitleTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined;
setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
@@ -45,30 +31,135 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
setSubtitleURL,
setAudioTrack,
}) => {
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
null
);
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
const ControlContext = useControlContext();
const isVideoLoaded = ControlContext?.isVideoLoaded;
const mediaSource = ControlContext?.mediaSource;
const allSubs = mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
const { itemId, audioIndex, bitrateValue, subtitleIndex } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const onTextBasedSubtitle = useMemo(
() =>
allSubs.find((s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream) || subtitleIndex === "-1",
[allSubs, subtitleIndex]
);
const setPlayerParams = ({
chosenAudioIndex = audioIndex,
chosenSubtitleIndex = subtitleIndex,
}: {
chosenAudioIndex?: string;
chosenSubtitleIndex?: string;
}) => {
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
const queryParams = new URLSearchParams({
itemId: itemId ?? "",
audioIndex: chosenAudioIndex,
subtitleIndex: chosenSubtitleIndex,
mediaSourceId: mediaSource?.Id ?? "",
bitrateValue: bitrateValue,
}).toString();
//@ts-ignore
router.replace(`player/direct-player?${queryParams}`);
};
const setTrackParams = (type: "audio" | "subtitle", index: number, serverIndex: number) => {
const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack;
const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex";
// 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.
const shouldChangePlayerParams = type === "subtitle" && mediaSource?.TranscodingUrl && !onTextBasedSubtitle;
console.log("Set player params", index, serverIndex);
if (shouldChangePlayerParams) {
setPlayerParams({
chosenSubtitleIndex: serverIndex.toString(),
});
return;
}
setTrack && setTrack(index);
router.setParams({
[paramKey]: serverIndex.toString(),
});
};
useEffect(() => {
const fetchTracks = async () => {
if (
getSubtitleTracks &&
(subtitleTracks === null || subtitleTracks.length === 0)
) {
const subtitles = await getSubtitleTracks();
console.log("Getting embeded subtitles...", subtitles);
if (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;
const processedSubs: Track[] = sortedSubs?.map((sub) => {
// Always increment for non-transcoding subtitles
// Only increment for text-based subtitles when transcoding
const shouldIncrement = !mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1;
const finalIndex = shouldIncrement ? vlcIndex : sub.Index ?? -1;
if (shouldIncrement) textSubIndex++;
return {
name: sub.DisplayTitle || "Undefined Subtitle",
index: sub.Index ?? -1,
setTrack: () =>
shouldIncrement
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1)
: setPlayerParams({
chosenSubtitleIndex: sub.Index?.toString(),
}),
};
});
// Step 3: Restore the original order
const subtitles: Track[] = processedSubs.sort((a, b) => a.index - b.index);
// Add a "Disable Subtitles" option
subtitles.unshift({
name: "Disable",
index: -1,
setTrack: () =>
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
? setTrackParams("subtitle", -1, -1)
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
});
setSubtitleTracks(subtitles);
}
if (
getAudioTracks &&
(audioTracks === null || audioTracks.length === 0)
) {
const audio = await getAudioTracks();
setAudioTracks(audio);
if (getAudioTracks) {
const audioData = await getAudioTracks();
const allAudio = mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
if (!mediaSource?.TranscodingUrl) {
const vlcIndex = audioData?.at(idx)?.index ?? -1;
return {
name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1,
setTrack: () => setTrackParams("audio", vlcIndex, audio.Index ?? -1),
};
}
return {
name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1,
setTrack: () => setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
};
});
setAudioTracks(audioTracks);
}
};
fetchTracks();

View File

@@ -0,0 +1,121 @@
import React, { useCallback } from "react";
import { TouchableOpacity, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useVideoContext } from "../contexts/VideoContext";
import { useLocalSearchParams, useRouter } from "expo-router";
import { BITRATES } from "@/components/BitrateSelector";
import { useControlContext } from "../contexts/ControlContext";
const DropdownView = () => {
const videoContext = useVideoContext();
const { subtitleTracks, audioTracks } = videoContext;
const ControlContext = useControlContext();
const [item, mediaSource] = [ControlContext?.item, ControlContext?.mediaSource];
const router = useRouter();
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: 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 (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="qualitytrigger">Quality</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
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
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{subtitleTracks?.map((sub, idx: number) => (
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
value={subtitleIndex === sub.index.toString()}
onValueChange={() => sub.setTrack()}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>{sub.name}</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">Audio</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{audioTracks?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={audioIndex === track.index.toString()}
onValueChange={() => track.setTrack()}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>{track.name}</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};
export default DropdownView;

View File

@@ -1,158 +0,0 @@
import React, { useMemo, useState } from "react";
import { View, TouchableOpacity, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useControlContext } from "../contexts/ControlContext";
import { useVideoContext } from "../contexts/VideoContext";
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { router, useLocalSearchParams } from "expo-router";
interface DropdownViewDirectProps {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
showControls,
offline = false,
}) => {
const api = useAtomValue(apiAtom);
const ControlContext = useControlContext();
const mediaSource = ControlContext?.mediaSource;
const item = ControlContext?.item;
const isVideoLoaded = ControlContext?.isVideoLoaded;
const videoContext = useVideoContext();
const {
subtitleTracks,
audioTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
} = videoContext;
const allSubtitleTracksForDirectPlay = useMemo(() => {
if (mediaSource?.TranscodingUrl) return null;
const embeddedSubs =
subtitleTracks
?.map((s) => ({
name: s.name,
index: s.index,
deliveryUrl: undefined,
}))
.filter((sub) => !sub.name.endsWith("[External]")) || [];
const externalSubs =
mediaSource?.MediaStreams?.filter(
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
).map((s) => ({
name: s.DisplayTitle! + " [External]",
index: s.Index!,
deliveryUrl: s.DeliveryUrl,
})) || [];
// Combine embedded subs with external subs only if not offline
if (!offline) {
return [...embeddedSubs, ...externalSubs] as (
| EmbeddedSubtitle
| ExternalSubtitle
)[];
}
return embeddedSubs as EmbeddedSubtitle[];
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]);
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
value={subtitleIndex === sub.index.toString()}
onValueChange={() => {
if ("deliveryUrl" in sub && sub.deliveryUrl) {
setSubtitleURL &&
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
} else {
setSubtitleTrack && setSubtitleTrack(sub.index);
}
router.setParams({
subtitleIndex: sub.index.toString(),
});
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{audioTracks?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={audioIndex === track.index.toString()}
onValueChange={() => {
setAudioTrack && setAudioTrack(track.index);
router.setParams({
audioIndex: track.index.toString(),
});
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};
export default DropdownViewDirect;

View File

@@ -1,228 +0,0 @@
import React, { useCallback, useMemo, useState } from "react";
import { View, TouchableOpacity, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useControlContext } from "../contexts/ControlContext";
import { useVideoContext } from "../contexts/VideoContext";
import { TranscodedSubtitle } from "../types";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useLocalSearchParams, useRouter } from "expo-router";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
interface DropdownViewProps {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
const router = useRouter();
const api = useAtomValue(apiAtom);
const ControlContext = useControlContext();
const mediaSource = ControlContext?.mediaSource;
const item = ControlContext?.item;
const isVideoLoaded = ControlContext?.isVideoLoaded;
const videoContext = useVideoContext();
const { subtitleTracks, setSubtitleTrack } = videoContext;
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
const isOnTextSubtitle = useMemo(() => {
const res = Boolean(
mediaSource?.MediaStreams?.find(
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
) || subtitleIndex === "-1"
);
return res;
}, []);
const allSubs =
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
const subtitleHelper = new SubtitleHelper(mediaSource?.MediaStreams ?? []);
const allSubtitleTracksForTranscodingStream = useMemo(() => {
const disableSubtitle = {
name: "Disable",
index: -1,
IsTextSubtitleStream: true,
} as TranscodedSubtitle;
if (isOnTextSubtitle) {
const textSubtitles =
subtitleTracks?.map((s) => ({
name: s.name,
index: s.index,
IsTextSubtitleStream: true,
})) || [];
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
return [disableSubtitle, ...sortedSubtitles];
}
const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({
name: x.DisplayTitle!,
index: x.Index!,
IsTextSubtitleStream: x.IsTextSubtitleStream!,
}));
return [disableSubtitle, ...transcodedSubtitle];
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
const changeToImageBasedSub = useCallback(
(subtitleIndex: number) => {
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue,
}).toString();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
},
[mediaSource]
);
// Audio tracks for transcoding streams.
const allAudio =
mediaSource?.MediaStreams?.filter((x) => x.Type === "Audio").map((x) => ({
name: x.DisplayTitle!,
index: x.Index!,
})) || [];
const ChangeTranscodingAudio = useCallback(
(audioIndex: number) => {
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue,
}).toString();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
},
[mediaSource, subtitleIndex, audioIndex]
);
return (
<View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allSubtitleTracksForTranscodingStream?.map(
(sub, idx: number) => (
<DropdownMenu.CheckboxItem
value={
subtitleIndex ===
(isOnTextSubtitle && sub.IsTextSubtitleStream
? subtitleHelper
.getSourceSubtitleIndex(sub.index)
.toString()
: sub?.index.toString())
}
key={`subtitle-item-${idx}`}
onValueChange={() => {
if (
subtitleIndex ===
(isOnTextSubtitle && sub.IsTextSubtitleStream
? subtitleHelper
.getSourceSubtitleIndex(sub.index)
.toString()
: sub?.index.toString())
)
return;
router.setParams({
subtitleIndex: subtitleHelper
.getSourceSubtitleIndex(sub.index)
.toString(),
});
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
setSubtitleTrack && setSubtitleTrack(sub.index);
return;
}
changeToImageBasedSub(sub.index);
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
)
)}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allAudio?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={audioIndex === track.index.toString()}
onValueChange={() => {
if (audioIndex === track.index.toString()) return;
router.setParams({
audioIndex: track.index.toString(),
});
ChangeTranscodingAudio(track.index);
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};
export default DropdownView;

View File

@@ -13,7 +13,14 @@ type ExternalSubtitle = {
type TranscodedSubtitle = {
name: string;
index: number;
deliveryUrl: string;
IsTextSubtitleStream: boolean;
};
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle };
type Track = {
name: string;
index: number;
setTrack: () => void;
};
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };

View File

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

View File

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

View File

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

View File

@@ -1,15 +0,0 @@
--- expo.js.original 2024-11-10 09:08:19
+++ node_modules/react-native-edge-to-edge/dist/commonjs/expo.js 2024-11-10 09:08:23
@@ -19,10 +19,8 @@
const {
barStyle
} = androidStatusBar;
+ const android = props?.android || {};
const {
- android = {}
- } = props;
- const {
parentTheme = "Default"
} = android;
config.modResults.resources.style = config.modResults.resources.style?.map(style => {
\ No newline at end of file

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 { Results } from "@/utils/jellyseerr/server/models/Search";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import { storage } from "@/utils/mmkv";
import { inRange } from "lodash";
import { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
@@ -14,7 +14,7 @@ import {
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
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 {
SeasonWithEpisodes,
@@ -227,6 +227,23 @@ export class JellyseerrApi {
.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) {
return this.axios
?.get<MovieDetails>(Endpoints.API_V1 + Endpoints.MOVIE + `/${id}`)
@@ -439,22 +456,56 @@ export const useJellyseerr = () => {
);
const isJellyseerrResult = (
items: any[] | null | undefined
): items is Results[] => {
items: any | null | undefined
): items is Results => {
return (
!items ||
(items.length >= 0 &&
Object.hasOwn(items[0], "mediaType") &&
Object.values(MediaType).includes(items[0]["mediaType"]))
);
items &&
Object.hasOwn(items, "mediaType") &&
Object.values(MediaType).includes(items["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 {
jellyseerrApi,
jellyseerrUser,
setJellyseerrUser,
clearAllJellyseerData,
isJellyseerrResult,
getTitle,
getYear,
getMediaType,
jellyseerrRegion,
jellyseerrLocale,
requestMedia,
};
};

View File

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

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

@@ -39,7 +39,7 @@ export type VlcPlayerSource = {
type?: string;
isNetwork?: boolean;
autoplay?: boolean;
externalTrack?: { name: string, DeliveryUrl: string };
externalSubtitles: { name: string; DeliveryUrl: string }[];
initOptions?: any[];
mediaOptions?: { [key: string]: any };
startPosition?: number;

View File

@@ -6,16 +6,30 @@ import {
VlcPlayerViewRef,
VlcPlayerSource,
} from "./VlcPlayer.types";
import {useSettings, VideoPlayer} from "@/utils/atoms/settings";
import {Platform} from "react-native";
interface NativeViewRef extends VlcPlayerViewRef {
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
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>(

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

@@ -0,0 +1,38 @@
package expo.modules.vlcplayer
import expo.modules.core.interfaces.ReactActivityLifecycleListener
// TODO: Creating a separate package class and adding this as a lifecycle listener did not work...
// https://docs.expo.dev/modules/android-lifecycle-listeners/
object VLCManager: ReactActivityLifecycleListener {
val listeners: MutableList<ReactActivityLifecycleListener> = mutableListOf()
// override fun onCreate(activity: Activity?, savedInstanceState: Bundle?) {
// listeners.forEach {
// it.onCreate(activity, savedInstanceState)
// }
// }
//
// override fun onResume(activity: Activity?) {
// listeners.forEach {
// it.onResume(activity)
// }
// }
//
// override fun onPause(activity: Activity?) {
// listeners.forEach {
// it.onPause(activity)
// }
// }
//
// override fun onUserLeaveHint(activity: Activity?) {
// listeners.forEach {
// it.onUserLeaveHint(activity)
// }
// }
//
// override fun onDestroy(activity: Activity?) {
// listeners.forEach {
// it.onDestroy(activity)
// }
// }
}

View File

@@ -1,5 +1,6 @@
package expo.modules.vlcplayer
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
@@ -7,6 +8,18 @@ class VlcPlayerModule : Module() {
override fun definition() = ModuleDefinition {
Name("VlcPlayer")
OnActivityEntersForeground {
VLCManager.listeners.forEach {
it.onResume(appContext.currentActivity)
}
}
OnActivityEntersBackground {
VLCManager.listeners.forEach {
it.onPause(appContext.currentActivity)
}
}
View(VlcPlayerView::class) {
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
view.setSource(source)

View File

@@ -1,6 +1,7 @@
package expo.modules.vlcplayer
import android.R
import android.app.Activity
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
@@ -14,13 +15,20 @@ import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import androidx.annotation.RequiresApi
import androidx.core.app.ComponentActivity
import androidx.core.content.ContextCompat
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import expo.modules.core.interfaces.ReactActivityLifecycleListener
import expo.modules.core.logging.LogHandlers
import expo.modules.core.logging.Logger
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
@@ -31,7 +39,8 @@ import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.util.VLCVideoLayout
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener {
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener, ReactActivityLifecycleListener {
private val log = Logger(listOf(LogHandlers.createOSLogHandler(this::class.simpleName!!)))
private val PIP_PLAY_PAUSE_ACTION = "PIP_PLAY_PAUSE_ACTION"
private val PIP_REWIND_ACTION = "PIP_REWIND_ACTION"
private val PIP_FORWARD_ACTION = "PIP_FORWARD_ACTION"
@@ -43,6 +52,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var lastReportedState: Int? = null
private var lastReportedIsPlaying: Boolean? = null
private var media : Media? = null
private var timeLeft: Long? = null
private val onVideoProgress by EventDispatcher()
private val onVideoStateChange by EventDispatcher()
@@ -64,53 +74,87 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
private val currentActivity get() = context.findActivity()
private val actions: MutableList<RemoteAction> = mutableListOf()
private val actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
private val remoteActionFilter = IntentFilter()
private val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
private val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
private val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
private var actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
PIP_PLAY_PAUSE_ACTION -> if (isPaused) play() else pause()
PIP_PLAY_PAUSE_ACTION -> {
if (isPaused) play() else pause()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setupPipActions()
currentActivity.setPictureInPictureParams(getPipParams()!!)
}
}
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
}
}
}
init {
setupView()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setupPipActions()
currentActivity.apply {
setPictureInPictureParams(getPipParams()!!)
addOnPictureInPictureModeChangedListener { info ->
onPipStarted(mapOf(
"pipStarted" to info.isInPictureInPictureMode
))
}
private var pipChangeListener: (PictureInPictureModeChangedInfo) -> Unit = { info ->
if (!info.isInPictureInPictureMode && mediaPlayer?.isPlaying == true) {
log.debug("Exiting PiP")
timeLeft = mediaPlayer?.time
pause()
// Setting the media after reattaching the view allows for a fast video view render
if (mediaPlayer?.vlcVout?.areViewsAttached() == false) {
mediaPlayer?.attachViews(videoLayout, null, false, false)
mediaPlayer?.media = media
mediaPlayer?.play()
timeLeft?.let { mediaPlayer?.time = it }
mediaPlayer?.pause()
}
}
onPipStarted(mapOf(
"pipStarted" to info.isInPictureInPictureMode
))
}
init {
VLCManager.listeners.add(this)
setupView()
setupPiP()
}
private fun setupView() {
Log.d("VlcPlayerView", "Setting up view")
log.debug("Setting up view")
setBackgroundColor(android.graphics.Color.WHITE)
videoLayout = VLCVideoLayout(context).apply {
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
videoLayout.keepScreenOn = true
addView(videoLayout)
Log.d("VlcPlayerView", "View setup complete")
log.debug("View setup complete")
}
private fun setupPiP() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
remoteActionFilter.addAction(PIP_REWIND_ACTION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
currentActivity.registerReceiver(
actionReceiver,
remoteActionFilter,
Context.RECEIVER_NOT_EXPORTED
)
}
setupPipActions()
currentActivity.apply {
setPictureInPictureParams(getPipParams()!!)
addOnPictureInPictureModeChangedListener(pipChangeListener)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun setupPipActions() {
val remoteActionFilter = IntentFilter()
val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
remoteActionFilter.addAction(PIP_REWIND_ACTION)
actions.clear()
actions.addAll(
listOf(
RemoteAction(
@@ -125,12 +169,13 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
)
),
RemoteAction(
Icon.createWithResource(context, R.drawable.ic_media_play),
if (isPaused) Icon.createWithResource(context, R.drawable.ic_media_play)
else Icon.createWithResource(context, R.drawable.ic_media_pause),
"Play",
"Play Video",
PendingIntent.getBroadcast(
context,
0,
if (isPaused) 0 else 1,
playPauseIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
)
@@ -148,13 +193,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
)
)
)
ContextCompat.registerReceiver(
context,
actionReceiver,
remoteActionFilter,
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
private fun getPipParams(): PictureInPictureParams? {
@@ -171,7 +209,9 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
fun setSource(source: Map<String, Any>) {
log.debug("setting source $source")
if (hasSource) {
log.debug("Source already set. Resuming")
mediaPlayer?.attachViews(videoLayout, null, false, false)
play()
return
@@ -196,12 +236,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
mediaPlayer?.attachViews(videoLayout, null, false, false)
mediaPlayer?.setEventListener(this)
Log.d("VlcPlayerView", "Loading network file: $uri")
log.debug("Loading network file: $uri")
media = Media(libVLC, Uri.parse(uri))
mediaPlayer?.media = media
Log.d("VlcPlayerView", "Debug: Media options: $mediaOptions")
log.debug("Debug: Media options: $mediaOptions")
// media.addOptions(mediaOptions)
// Apply subtitle options
@@ -218,7 +258,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
hasSource = true
if (autoplay) {
Log.d("VlcPlayerView", "Playing...")
log.debug("Playing...")
play()
}
}
@@ -268,9 +308,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
fun getAudioTracks(): List<Map<String, Any>>? {
println("getAudioTracks")
println(mediaPlayer?.getAudioTracks())
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
return trackDescriptions.map { trackDescription ->
@@ -294,19 +332,32 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
// Debug statement to print the result
Log.d("VlcPlayerView", "Subtitle Tracks: $subtitleTracks")
log.debug("Subtitle Tracks: $subtitleTracks")
return subtitleTracks
}
fun setSubtitleURL(subtitleURL: String, name: String) {
println("Setting subtitle URL: $subtitleURL, name: $name")
log.debug("Setting subtitle URL: $subtitleURL, name: $name")
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
}
override fun onDetachedFromWindow() {
println("onDetachedFromWindow")
log.debug("onDetachedFromWindow")
super.onDetachedFromWindow()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
currentActivity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
)
}
currentActivity.unregisterReceiver(actionReceiver)
currentActivity.removeOnPictureInPictureModeChangedListener(pipChangeListener)
VLCManager.listeners.clear()
mediaPlayer?.stop()
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
@@ -319,6 +370,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
override fun onEvent(event: MediaPlayer.Event) {
keepScreenOn = event.type == MediaPlayer.Event.Playing || event.type == MediaPlayer.Event.Buffering
when (event.type) {
MediaPlayer.Event.Playing,
MediaPlayer.Event.Paused,
@@ -340,35 +392,27 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
"target" to "null", // Replace with actual target if needed
"currentTime" to player.time.toInt(),
"duration" to (player.media?.duration?.toInt() ?: 0),
"error" to false
"error" to false,
"isPlaying" to (currentState == MediaPlayer.Event.Playing),
"isBuffering" to (!player.isPlaying && currentState == MediaPlayer.Event.Buffering)
)
// Todo: make enum - string to prevent this when statement from becoming exhaustive
when (currentState) {
MediaPlayer.Event.Playing -> {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
MediaPlayer.Event.Playing ->
stateInfo["state"] = "Playing"
}
MediaPlayer.Event.Paused -> {
stateInfo["isPlaying"] = false
MediaPlayer.Event.Paused ->
stateInfo["state"] = "Paused"
}
MediaPlayer.Event.Buffering -> {
stateInfo["isBuffering"] = true
MediaPlayer.Event.Buffering ->
stateInfo["state"] = "Buffering"
}
MediaPlayer.Event.EncounteredError -> {
Log.e("VlcPlayerView", "player.state ~ error")
stateInfo["state"] = "Error"
onVideoLoadEnd(stateInfo);
}
MediaPlayer.Event.Opening -> {
Log.d("VlcPlayerView", "player.state ~ opening")
MediaPlayer.Event.Opening ->
stateInfo["state"] = "Opening"
}
}
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
lastReportedState = currentState
lastReportedIsPlaying = player.isPlaying
@@ -400,6 +444,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
));
}
}
override fun onPause(activity: Activity?) {
log.debug("Pausing activity...")
}
override fun onResume(activity: Activity?) {
log.debug("Resuming activity...")
if (isPaused) play()
}
}
internal fun Context.findActivity(): androidx.activity.ComponentActivity {

View File

@@ -1,7 +1,8 @@
{
"platforms": ["ios", "tvos", "android", "web"],
"ios": {
"modules": ["VlcPlayerModule"]
"modules": ["VlcPlayerModule"],
"appDelegateSubscribers": ["AppLifecycleDelegate"]
},
"android": {
"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] = []
}

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