Compare commits

..

3 Commits

Author SHA1 Message Date
Fredrik Burmester
09189e125e fix: change to yarn 2025-02-09 13:05:18 +01:00
Fredrik Burmester
1ac10d8f34 fix: expo doctor issues 2025-02-09 13:05:07 +01:00
sarendsen
d5fe354986 wip 2025-02-08 16:29:12 +01:00
140 changed files with 12974 additions and 10903 deletions

1
.gitattributes vendored
View File

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

View File

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

View File

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

5
.gitignore vendored
View File

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

View File

@@ -9,7 +9,6 @@
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"prettier.printWidth": 120,
"[swift]": { "[swift]": {
"editor.defaultFormatter": "sswg.swift-lang" "editor.defaultFormatter": "sswg.swift-lang"
} }

View File

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

View File

@@ -18,7 +18,6 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
- 🔊 **Background audio**: Stream music in the background, even when locking the phone. - 🔊 **Background audio**: Stream music in the background, even when locking the phone.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline. - 📥 **Download media** (Experimental): Save your media locally and watch it offline.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device. - 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
- 🤖 **Jellyseerr integration**: Request media directly in the app. - 🤖 **Jellyseerr integration**: Request media directly in the app.
## 🧪 Experimental Features ## 🧪 Experimental Features
@@ -38,7 +37,7 @@ Chromecast support is still in development, and we're working on improving it. C
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like: The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
- Auto log in to Jellyseerr without the user having to do anythin - Auto log in to Jellyseerr without the user having to do anythin
- Choose the default languages - Choose the default languages
- Set download method and search provider - Set download method and search provider
- Customize homescreen - Customize homescreen
- And more... - And more...
@@ -68,7 +67,7 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you. To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas. **Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
## 🚀 Getting Started ## 🚀 Getting Started
@@ -85,9 +84,9 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
1. Use node `>20` 1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload` 2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/) 3. Make sure you have xcode and/or android studio installed.
4. run `npm run prebuild` 4. run `npm run prebuild`
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app. 5. Create an expo dev build by running `npm run ios` or `nom run android`. This will open a simulator on your computer and run the app.
For the TV version suffix the npm commands with `:tv`. For the TV version suffix the npm commands with `:tv`.
@@ -123,85 +122,7 @@ Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmest
## ✨ Acknowledgements ## ✨ Acknowledgements
### Core Developers I'd like to thank the following people and projects for their contributions to Streamyfin:
Thanks to the following contributors for their significant contributions:
<table>
<tr
style="
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
"
>
<td align="center">
<a href="https://github.com/Alexk2309">
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Alexk2309</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/herrrta">
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@herrrta</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/lostb1t">
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@lostb1t</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Simon-Eklundh">
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Simon-Eklundh</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/topiga">
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@topiga</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/simoncaron">
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@simoncaron</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/jakequade">
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@jakequade</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Ryan0204">
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Ryan0204</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/retardgerman">
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@retardgerman</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/whoopsi-daisy">
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@whoopsi-daisy</b></sub>
</a>
</td>
</tr>
</table>
And all other developers who have contributed to Streamyfin, thank you for your contributions.
I'd also like to thank the following people and projects for their contributions to Streamyfin:
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API. - [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK. - [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.

View File

@@ -2,19 +2,28 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.27.0", "version": "0.25.0",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain"
},
"jsEngine": "hermes", "jsEngine": "hermes",
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": [
"**/*"
],
"ios": { "ios": {
"requireFullScreen": true, "requireFullScreen": true,
"infoPlist": { "infoPlist": {
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.", "NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
"NSMicrophoneUsageDescription": "The app needs access to your microphone.", "NSMicrophoneUsageDescription": "The app needs access to your microphone.",
"UIBackgroundModes": ["audio", "fetch"], "UIBackgroundModes": [
"audio",
"fetch"
],
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.", "NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSAppTransportSecurity": { "NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true "NSAllowsArbitraryLoads": true
@@ -31,11 +40,9 @@
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 53, "versionCode": 50,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png", "foregroundImage": "./assets/images/adaptive_icon.png"
"backgroundColor": "#464646"
}, },
"package": "com.fredrikburmester.streamyfin", "package": "com.fredrikburmester.streamyfin",
"permissions": [ "permissions": [
@@ -70,10 +77,11 @@
"useFrameworks": "static" "useFrameworks": "static"
}, },
"android": { "android": {
"compileSdkVersion": 35, "android": {
"targetSdkVersion": 35, "compileSdkVersion": 34,
"buildToolsVersion": "35.0.0", "targetSdkVersion": 34,
"kotlinVersion": "2.0.21", "buildToolsVersion": "34.0.0"
},
"minSdkVersion": 24, "minSdkVersion": 24,
"usesCleartextTraffic": true, "usesCleartextTraffic": true,
"packagingOptions": { "packagingOptions": {
@@ -108,18 +116,17 @@
} }
} }
], ],
["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
[ [
"expo-splash-screen", "react-native-bottom-tabs"
{ ],
"backgroundColor": "#2e2e2e", [
"image": "./assets/images/StreamyFinFinal.png", "./plugins/withChangeNativeAndroidTextToWhite.js"
"imageWidth": 100 ],
} [
"./plugins/withGoogleCastActivity.js"
],
[
"./plugins/withTrustLocalCerts.js"
] ]
], ],
"experiments": { "experiments": {
@@ -142,4 +149,4 @@
}, },
"newArchEnabled": false "newArchEnabled": false
} }
} }

View File

@@ -1,18 +1,15 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Ionicons, Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null; import { lazy } from "react";
import { useAtom } from "jotai"; // const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
import { userAtom } from "@/providers/JellyfinProvider"; const Chromecast = lazy(() => import("@/components/Chromecast"));
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
export default function IndexLayout() { export default function IndexLayout() {
const router = useRouter(); const router = useRouter();
const [user] = useAtom(userAtom);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
@@ -31,11 +28,14 @@ export default function IndexLayout() {
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
{!Platform.isTV && ( {!Platform.isTV && (
<> <>
<Chromecast.Chromecast /> <Chromecast />
{user && user.Policy?.IsAdministrator && ( <TouchableOpacity
<SessionsButton /> onPress={() => {
)} router.push("/(auth)/settings");
<SettingsButton /> }}
>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
</> </>
)} )}
</View> </View>
@@ -54,12 +54,6 @@ export default function IndexLayout() {
title: t("home.downloads.tvseries"), title: t("home.downloads.tvseries"),
}} }}
/> />
<Stack.Screen
name="sessions/index"
options={{
title: t("home.sessions.title"),
}}
/>
<Stack.Screen <Stack.Screen
name="settings" name="settings"
options={{ options={{
@@ -120,38 +114,3 @@ export default function IndexLayout() {
</Stack> </Stack>
); );
} }
const SettingsButton = () => {
const router = useRouter();
return (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
);
};
const SessionsButton = () => {
const router = useRouter();
const { sessions = [], _ } = useSessions({} as useSessionsProps);
return (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/sessions");
}}
>
<View className="mr-4">
<Ionicons
name="play-circle"
color={sessions.length === 0 ? "white" : "#9333ea"}
size={25}
/>
</View>
</TouchableOpacity>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -153,7 +153,7 @@ export default function IndexLayout() {
disabled={settings.libraryOptions.imageStyle === "poster"} disabled={settings.libraryOptions.imageStyle === "poster"}
key="show-titles-option" key="show-titles-option"
value={settings.libraryOptions.showTitles} value={settings.libraryOptions.showTitles}
onValueChange={(newValue: string) => { onValueChange={(newValue) => {
if (settings.libraryOptions.imageStyle === "poster") if (settings.libraryOptions.imageStyle === "poster")
return; return;
updateSettings({ updateSettings({
@@ -172,7 +172,7 @@ export default function IndexLayout() {
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
key="show-stats-option" key="show-stats-option"
value={settings.libraryOptions.showStats} value={settings.libraryOptions.showStats}
onValueChange={(newValue: string) => { onValueChange={(newValue) => {
updateSettings({ updateSettings({
libraryOptions: { libraryOptions: {
...settings.libraryOptions, ...settings.libraryOptions,

View File

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

View File

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

View File

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

View File

@@ -1,33 +1,8 @@
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import React, { useEffect } from "react"; import React from "react";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings";
import { Platform } from "react-native";
export default function Layout() { 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 {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
}
};
}, [settings]);
return ( return (
<> <>
<SystemBars hidden /> <SystemBars hidden />
@@ -41,6 +16,15 @@ export default function Layout() {
animation: "fade", animation: "fade",
}} }}
/> />
<Stack.Screen
name="transcoding-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack> </Stack>
</> </>
); );

View File

@@ -3,47 +3,62 @@ import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener"; import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules"; import { VlcPlayerView } from "@/modules/vlc-player";
import { import {
PipStartedPayload,
PlaybackStatePayload, PlaybackStatePayload,
ProgressUpdatePayload, ProgressUpdatePayload,
VlcPlayerViewRef, VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types"; } from "@/modules/vlc-player/src/VlcPlayer.types";
const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null; // import { useDownload } from "@/providers/DownloadProvider";
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native"; import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { Api } from "@jellyfin/sdk";
import { getPlaystateApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; 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 { useHaptic } from "@/hooks/useHaptic";
import { useGlobalSearchParams, useNavigation } from "expo-router"; import { useFocusEffect, useGlobalSearchParams } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState, useEffect } from "react"; import React, {
import { Alert, View, Platform } from "react-native"; useCallback,
useMemo,
useRef,
useState,
useEffect,
} from "react";
import {
Alert,
BackHandler,
View,
AppState,
AppStateStatus,
Platform,
} from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
BaseItemDto,
MediaSourceInfo,
PlaybackOrder,
PlaybackProgressInfo,
PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
export default function page() { export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null); const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = useNavigation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
@@ -51,14 +66,13 @@ export default function page() {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true); const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false); const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPipStarted, setIsPipStarted] = useState(false);
const progress = useSharedValue(0); const progress = useSharedValue(0);
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0); const cacheProgress = useSharedValue(0);
let getDownloadedItem = null;
if (!Platform.isTV) { if (!Platform.isTV) {
getDownloadedItem = downloadProvider.useDownload(); const getDownloadedItem = downloadProvider.useDownload();
} }
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -86,115 +100,145 @@ export default function page() {
offline: string; offline: string;
}>(); }>();
const [settings] = useSettings(); const [settings] = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined; const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1; const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
const bitrateValue = bitrateValueStr ? parseInt(bitrateValueStr, 10) : BITRATES[0].value; const bitrateValue = bitrateValueStr
? parseInt(bitrateValueStr, 10)
: BITRATES[0].value;
const [item, setItem] = useState<BaseItemDto | null>(null); const {
const [itemStatus, setItemStatus] = useState({ data: item,
isLoading: true, isLoading: isLoadingItem,
isError: false, isError: isErrorItem,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (offline && !Platform.isTV) {
const item = await getDownloadedItem(itemId);
if (item) return item.item;
}
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
enabled: !!itemId,
staleTime: 0,
}); });
useEffect(() => { const {
const fetchItemData = async () => { data: stream,
setItemStatus({ isLoading: true, isError: false }); isLoading: isLoadingStreamUrl,
try { isError: isErrorStreamUrl,
let fetchedItem: BaseItemDto | null = null; } = useQuery({
if (offline && !Platform.isTV) { queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
const data = await getDownloadedItem.getDownloadedItem(itemId); queryFn: async () => {
if (data) fetchedItem = data.item as BaseItemDto; if (offline && !Platform.isTV) {
} else { const data = await getDownloadedItem(itemId);
const res = await getUserLibraryApi(api!).getItem({ if (!data?.mediaSource) return null;
itemId,
userId: user?.Id, const url = await getDownloadedFileUrl(data.item.Id!);
});
fetchedItem = res.data; if (item)
} return {
setItem(fetchedItem); mediaSource: data.mediaSource,
} catch (error) { url,
console.error("Failed to fetch item:", error); sessionId: undefined,
setItemStatus({ isLoading: false, isError: true }); };
} finally {
setItemStatus({ isLoading: false, isError: false });
} }
};
if (itemId) { const res = await getStreamUrl({
fetchItemData(); api,
} item,
}, [itemId, offline, api, user?.Id]); startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
interface Stream { if (!res) return null;
mediaSource: MediaSourceInfo;
sessionId: string;
url: string;
}
const [stream, setStream] = useState<Stream | null>(null); const { mediaSource, sessionId, url } = res;
const [streamStatus, setStreamStatus] = useState({
isLoading: true, if (!sessionId || !mediaSource || !url) {
isError: false, Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
return null;
}
return {
mediaSource,
sessionId,
url,
};
},
enabled: !!itemId && !!item,
staleTime: 0,
}); });
useEffect(() => { const togglePlay = useCallback(async () => {
const fetchStreamData = async () => { if (!api) return;
try {
let result: Stream | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) {
result = { mediaSource: data.mediaSource, sessionId: "", url };
}
} else {
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
return;
}
result = { mediaSource, sessionId, url };
}
setStream(result);
} catch (error) {
console.error("Failed to fetch stream:", error);
setStreamStatus({ isLoading: false, isError: true });
} finally {
setStreamStatus({ isLoading: false, isError: false });
}
};
fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
const togglePlay = async () => {
lightHapticFeedback(); lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: true,
playMethod: stream.url?.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
} else { } else {
videoRef.current?.play(); videoRef.current?.play();
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
} }
}; }, [
isPlaying,
api,
item,
stream,
videoRef,
audioIndex,
subtitleIndex,
mediaSourceId,
offline,
progress.value,
]);
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (offline) return; if (offline) return;
const currentTimeInTicks = msToTicks(progress.get());
const currentTimeInTicks = msToTicks(progress.value);
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item?.Id!,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
@@ -211,66 +255,56 @@ export default function page() {
videoRef.current?.stop(); videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]); }, [videoRef, reportPlaybackStopped]);
useEffect(() => { // TODO: unused should remove.
const beforeRemoveListener = navigation.addListener("beforeRemove", stop); const reportPlaybackStart = useCallback(async () => {
return () => { if (offline) return;
beforeRemoveListener();
};
}, [navigation, stop]);
const currentPlayStateInfo = () => { if (!stream) return;
return { await getPlaystateApi(api!).onPlaybackStart({
itemId: item?.Id!, itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined, audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()), playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
isPaused: !isPlaying, playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", });
playSessionId: stream.sessionId, }, [api, item, mediaSourceId, stream]);
isMuted: false,
canSeek: true,
repeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default,
};
};
const onProgress = useCallback( const onProgress = useCallback(
async (data: ProgressUpdatePayload) => { async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return; if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const { currentTime } = data.nativeEvent; const { currentTime } = data.nativeEvent;
if (isBuffering) { if (isBuffering) {
setIsBuffering(false); setIsBuffering(false);
} }
progress.set(currentTime); progress.value = currentTime;
if (offline) return; if (offline) return;
const currentTimeInTicks = msToTicks(currentTime);
if (!item?.Id || !stream) return; if (!item?.Id || !stream) return;
reportPlaybackProgress(); await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(currentTimeInTicks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
}, },
[item?.Id, audioIndex, subtitleIndex, mediaSourceId, isPlaying, stream, isSeeking, isPlaybackStopped, isBuffering] [item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
); );
const onPipStarted = useCallback((e: PipStartedPayload) => { useOrientation();
const { pipStarted } = e.nativeEvent; useOrientationSettings();
setIsPipStarted(pipStarted);
}, []);
const reportPlaybackProgress = useCallback(async () => {
if (!api || offline || !stream) return;
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
});
}, [api, isPlaying, offline, stream, item?.Id, audioIndex, subtitleIndex, mediaSourceId, progress]);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks ? ticksToSeconds(item.UserData.PlaybackPositionTicks) : 0;
}, [item]);
useWebSocket({ useWebSocket({
isPlaying: isPlaying, isPlaying: isPlaying,
@@ -279,81 +313,125 @@ export default function page() {
offline, offline,
}); });
const onPlaybackStateChanged = useCallback( const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
async (e: PlaybackStatePayload) => { const { state, isBuffering, isPlaying } = e.nativeEvent;
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
reportPlaybackProgress();
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
if (state === "Paused") { if (state === "Playing") {
setIsPlaying(false); setIsPlaying(true);
reportPlaybackProgress(); return;
if (!Platform.isTV) await deactivateKeepAwake(); }
return;
}
if (isPlaying) { if (state === "Paused") {
setIsPlaying(true); setIsPlaying(false);
setIsBuffering(false); return;
} else if (isBuffering) { }
setIsBuffering(true);
}
},
[reportPlaybackProgress]
);
const allAudio = stream?.mediaSource.MediaStreams?.filter((audio) => audio.Type === "Audio") || []; if (isPlaying) {
setIsPlaying(true);
// Move all the external subtitles last, because vlc places them last. setIsBuffering(false);
const allSubs = } else if (isBuffering) {
stream?.mediaSource.MediaStreams?.filter((sub) => sub.Type === "Subtitle").sort( setIsBuffering(true);
(a, b) => Number(a.IsExternal) - Number(b.IsExternal) }
) || [];
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find((sub) => sub.Index === subtitleIndex);
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
if (chosenSubtitleTrack && (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)) {
const finalIndex = notTranscoding ? allSubs.indexOf(chosenSubtitleTrack) : textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
}
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []); }, []);
if (itemStatus.isLoading || streamStatus.isLoading) { const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item]);
useFocusEffect(
React.useCallback(() => {
return async () => {
stop();
};
}, [])
);
const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.match(/inactive|background/) && nextAppState === "active") {
// Handle app coming to the foreground
} else if (nextAppState.match(/inactive|background/)) {
// Handle app going to the background
if (videoRef.current && videoRef.current.pause) {
videoRef.current.pause();
}
}
setAppState(nextAppState);
};
// Use AppState.addEventListener and return a cleanup function
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
return () => {
// Cleanup the event listener when the component is unmounted
subscription.remove();
};
}, [appState]);
// Preselection of audio and subtitle tracks.
if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
let externalTrack = { name: "", DeliveryUrl: "" };
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle"
) || [];
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex
);
const allAudio =
stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio"
) || [];
const chosenAudioTrack = allAudio.find((audio) => 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}`,
};
}
if (chosenAudioTrack)
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} else {
// Transcoded playback CASE
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
externalTrack = {
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
DeliveryUrl: "",
};
}
}
const insets = useSafeAreaInsets();
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
return ( return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black"> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader /> <Loader />
</View> </View>
); );
}
if (!item || !stream || itemStatus.isError || streamStatus.isError) if (isErrorItem || isErrorStreamUrl)
return ( return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black"> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text> <Text className="text-white">{t("player.error")}</Text>
@@ -377,29 +455,32 @@ export default function page() {
<VlcPlayerView <VlcPlayerView
ref={videoRef} ref={videoRef}
source={{ source={{
uri: stream?.url || "", uri: stream.url,
autoplay: true, autoplay: true,
isNetwork: true, isNetwork: true,
startPosition, startPosition,
externalSubtitles, externalTrack,
initOptions, initOptions,
}} }}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress} onVideoProgress={onProgress}
progressUpdateInterval={1000} progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged} onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted} onVideoLoadStart={() => {}}
onVideoLoadEnd={() => { onVideoLoadEnd={() => {
setIsVideoLoaded(true); setIsVideoLoaded(true);
}} }}
onVideoError={(e) => { onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert(t("player.error"), t("player.an_error_occured_while_playing_the_video")); Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video")
);
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}
/> />
</View> </View>
{videoRef.current && !isPipStarted && isMounted === true ? ( {videoRef.current && (
<Controls <Controls
mediaSource={stream?.mediaSource} mediaSource={stream?.mediaSource}
item={item} item={item}
@@ -415,7 +496,6 @@ export default function page() {
setIgnoreSafeAreas={setIgnoreSafeAreas} setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas} ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play} play={videoRef.current?.play}
pause={videoRef.current?.pause} pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo} seek={videoRef.current?.seekTo}
@@ -426,9 +506,29 @@ export default function page() {
setSubtitleTrack={videoRef.current.setSubtitleTrack} setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL} setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack} setAudioTrack={videoRef.current.setAudioTrack}
stop={stop}
isVlc isVlc
/> />
) : null} )}
</View> </View>
); );
} }
export function usePoster(
item: BaseItemDto,
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;
}

View File

@@ -0,0 +1,547 @@
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 = () => {
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,
]
);
useOrientation();
useOrientationSettings();
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,5 +1,6 @@
import "@/augmentations"; import "@/augmentations";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { Text } from "@/components/common/Text";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { import {
@@ -9,6 +10,10 @@ import {
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import {
SplashScreenProvider,
useSplashScreenLoading,
} from "@/providers/SplashScreenProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { Settings, useSettings } from "@/utils/atoms/settings"; import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks"; import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
@@ -27,15 +32,16 @@ const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch") ? require("expo-background-fetch")
: null; : null;
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useFonts } from "expo-font";
import { useKeepAwake } from "expo-keep-awake";
const Notifications = !Platform.isTV ? require("expo-notifications") : null; const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { router, Stack } from "expo-router"; import { router, Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null; const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { getLocales } from "expo-localization"; import { getLocales } from "expo-localization";
import { Provider as JotaiProvider } from "jotai"; import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider, useTranslation } from "react-i18next";
import { Appearance, AppState } from "react-native"; import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -52,39 +58,28 @@ if (!Platform.isTV) {
}); });
} }
// Keep the splash screen visible while we fetch resources
SplashScreen.preventAutoHideAsync();
// Set the animation options. This is optional.
SplashScreen.setOptions({
duration: 500,
fade: true,
});
function useNotificationObserver() { function useNotificationObserver() {
if (Platform.isTV) return; if (Platform.isTV) return;
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
function redirect(notification: typeof Notifications.Notification) { function redirect(notification: Notifications.Notification) {
const url = notification.request.content.data?.url; const url = notification.request.content.data?.url;
if (url) { if (url) {
router.push(url); router.push(url);
} }
} }
Notifications.getLastNotificationResponseAsync().then( Notifications.getLastNotificationResponseAsync().then((response) => {
(response: { notification: any }) => { if (!isMounted || !response?.notification) {
if (!isMounted || !response?.notification) { return;
return;
}
redirect(response?.notification);
} }
); redirect(response?.notification);
});
const subscription = Notifications.addNotificationResponseReceivedListener( const subscription = Notifications.addNotificationResponseReceivedListener(
(response: { notification: any }) => { (response) => {
redirect(response.notification); redirect(response.notification);
} }
); );
@@ -132,7 +127,7 @@ if (!Platform.isTV) {
const downloadUrl = url + "download/" + job.id; const downloadUrl = url + "download/" + job.id;
const tasks = await BackGroundDownloader.checkForExistingDownloads(); const tasks = await BackGroundDownloader.checkForExistingDownloads();
if (tasks.find((task: { id: string }) => task.id === job.id)) { if (tasks.find((task) => task.id === job.id)) {
console.log("TaskManager ~ Download already in progress: ", job.id); console.log("TaskManager ~ Download already in progress: ", job.id);
continue; continue;
} }
@@ -168,9 +163,9 @@ if (!Platform.isTV) {
trigger: null, trigger: null,
}); });
}) })
.error((error: any) => { .error((error) => {
console.log("TaskManager ~ Download error: ", job.id, error); console.log("TaskManager ~ Download error: ", job.id, error);
BackGroundDownloader.completeHandler(job.id); completeHandler(job.id);
Notifications.scheduleNotificationAsync({ Notifications.scheduleNotificationAsync({
content: { content: {
title: job.item.Name, title: job.item.Name,
@@ -227,15 +222,17 @@ export default function RootLayout() {
Appearance.setColorScheme("dark"); Appearance.setColorScheme("dark");
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <SplashScreenProvider>
<JotaiProvider> <GestureHandlerRootView style={{ flex: 1 }}>
<ActionSheetProvider> <JotaiProvider>
<I18nextProvider i18n={i18n}> <ActionSheetProvider>
<Layout /> <I18nextProvider i18n={i18n}>
</I18nextProvider> <Layout />
</ActionSheetProvider> </I18nextProvider>
</JotaiProvider> </ActionSheetProvider>
</GestureHandlerRootView> </JotaiProvider>
</GestureHandlerRootView>
</SplashScreenProvider>
); );
} }
@@ -262,23 +259,22 @@ function Layout() {
}, [settings?.preferedLanguage, i18n]); }, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) { if (!Platform.isTV) {
useKeepAwake();
useNotificationObserver(); useNotificationObserver();
const { i18n } = useTranslation();
useEffect(() => { useEffect(() => {
checkAndRequestPermissions(); checkAndRequestPermissions();
}, []); }, []);
useEffect(() => { useEffect(() => {
// If the user has auto rotate enabled, unlock the orientation if (settings?.autoRotate === true)
if (Platform.isTV) return; ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
if (settings.autoRotate === true) { else
ScreenOrientation.unlockAsync();
} else {
// If the user has auto rotate disabled, lock the orientation to portrait
ScreenOrientation.lockAsync( ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP ScreenOrientation.OrientationLock.PORTRAIT_UP
); );
}
}, [settings]); }, [settings]);
useEffect(() => { useEffect(() => {
@@ -302,6 +298,16 @@ function Layout() {
}, []); }, []);
} }
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
useSplashScreenLoading(!loaded);
if (!loaded) {
return null;
}
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<JobQueueProvider> <JobQueueProvider>
@@ -313,7 +319,7 @@ function Layout() {
<BottomSheetModalProvider> <BottomSheetModalProvider>
<SystemBars style="light" hidden={false} /> <SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}> <ThemeProvider value={DarkTheme}>
<Stack initialRouteName="(auth)/(tabs)"> <Stack>
<Stack.Screen <Stack.Screen
name="(auth)/(tabs)" name="(auth)/(tabs)"
options={{ options={{

View File

@@ -9,7 +9,7 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom, useAtomValue } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
@@ -19,20 +19,17 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { Keyboard } from "react-native";
import { z } from "zod"; import { z } from "zod";
import { t } from "i18next"; import { t } from 'i18next';
const CredentialsSchema = z.object({ const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")), username: z.string().min(1, t("login.username_required")),});
});
const Login: React.FC = () => { const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const { setServer, login, removeServer, initiateQuickConnect } = const { setServer, login, removeServer, initiateQuickConnect } =
useJellyfin(); useJellyfin();
const [api] = useAtom(apiAtom);
const params = useLocalSearchParams();
const { const {
apiUrl: _apiUrl, apiUrl: _apiUrl,
@@ -40,8 +37,6 @@ const Login: React.FC = () => {
password: _password, password: _password,
} = params as { apiUrl: string; username: string; password: string }; } = params as { apiUrl: string; username: string; password: string };
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl); const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>(""); const [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{ const [credentials, setCredentials] = useState<{
@@ -52,11 +47,10 @@ const Login: React.FC = () => {
password: _password, password: _password,
}); });
/**
* A way to auto login based on a link
*/
useEffect(() => { useEffect(() => {
(async () => { (async () => {
// we might re-use the checkUrl function here to check the url as well
// however, I don't think it should be necessary for now
if (_apiUrl) { if (_apiUrl) {
setServer({ setServer({
address: _apiUrl, address: _apiUrl,
@@ -72,6 +66,7 @@ const Login: React.FC = () => {
})(); })();
}, [_apiUrl, _username, _password]); }, [_apiUrl, _username, _password]);
const navigation = useNavigation();
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerTitle: serverName, headerTitle: serverName,
@@ -84,17 +79,15 @@ const Login: React.FC = () => {
className="flex flex-row items-center" className="flex flex-row items-center"
> >
<Ionicons name="chevron-back" size={18} color={Colors.primary} /> <Ionicons name="chevron-back" size={18} color={Colors.primary} />
<Text className="ml-2 text-purple-600"> <Text className="ml-2 text-purple-600">{t("login.change_server")}</Text>
{t("login.change_server")}
</Text>
</TouchableOpacity> </TouchableOpacity>
) : null, ) : null,
}); });
}, [serverName, navigation, api?.basePath]); }, [serverName, navigation, api?.basePath]);
const handleLogin = async () => { const [loading, setLoading] = useState<boolean>(false);
Keyboard.dismiss();
const handleLogin = async () => {
setLoading(true); setLoading(true);
try { try {
const result = CredentialsSchema.safeParse(credentials); const result = CredentialsSchema.safeParse(credentials);
@@ -105,16 +98,15 @@ const Login: React.FC = () => {
if (error instanceof Error) { if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message); Alert.alert(t("login.connection_failed"), error.message);
} else { } else {
Alert.alert( Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
t("login.connection_failed"),
t("login.an_unexpected_error_occured")
);
} }
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
/** /**
* Checks the availability and validity of a Jellyfin server URL. * Checks the availability and validity of a Jellyfin server URL.
* *
@@ -188,21 +180,14 @@ const Login: React.FC = () => {
try { try {
const code = await initiateQuickConnect(); const code = await initiateQuickConnect();
if (code) { if (code) {
Alert.alert( Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
t("login.quick_connect"), {
t("login.enter_code_to_login", { code: code }), text: t("login.got_it"),
[ },
{ ]);
text: t("login.got_it"),
},
]
);
} }
} catch (error) { } catch (error) {
Alert.alert( Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect"));
t("login.error_title"),
t("login.failed_to_initiate_quick_connect")
);
} }
}; };
@@ -216,18 +201,16 @@ const Login: React.FC = () => {
<View className="flex flex-col h-full relative items-center justify-center"> <View className="flex flex-col h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full"> <View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2"> <View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold -mb-2"> <Text className="text-2xl font-bold -mb-2">
<> <>
{serverName ? ( {serverName ? (
<> <>
{t("login.login_to_title") + " "} {t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text> <Text className="text-purple-600">{serverName}</Text>
</> </>
) : ( ) : t("login.login_title")}
t("login.login_title") </>
)} </Text>
</>
</Text>
<Text className="text-xs text-neutral-400"> <Text className="text-xs text-neutral-400">
{api.basePath} {api.basePath}
</Text> </Text>
@@ -237,6 +220,7 @@ const Login: React.FC = () => {
setCredentials({ ...credentials, username: text }) setCredentials({ ...credentials, username: text })
} }
value={credentials.username} value={credentials.username}
autoFocus
secureTextEntry={false} secureTextEntry={false}
keyboardType="default" keyboardType="default"
returnKeyType="done" returnKeyType="done"
@@ -316,9 +300,7 @@ const Login: React.FC = () => {
<Button <Button
loading={loadingServerCheck} loading={loadingServerCheck}
disabled={loadingServerCheck} disabled={loadingServerCheck}
onPress={async () => { onPress={async () => await handleConnect(serverURL)}
await handleConnect(serverURL);
}}
className="w-full grow" className="w-full grow"
> >
{t("server.connect_button")} {t("server.connect_button")}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 91 KiB

2929
bun.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -17,7 +17,7 @@ interface Props extends ViewProps {
background?: "blur" | "transparent"; background?: "blur" | "transparent";
} }
export function Chromecast({ export default function Chromecast({
width = 48, width = 48,
height = 48, height = 48,
background = "transparent", background = "transparent",

View File

@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue"; import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server"; import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
@@ -21,7 +21,7 @@ import {
import { Href, router, useFocusEffect } from "expo-router"; import { Href, router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react"; import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Platform, View, ViewProps } from "react-native"; import { Alert, View, ViewProps } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector"; import { Bitrate, BitrateSelector } from "./BitrateSelector";
@@ -66,12 +66,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1); const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] = const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0); useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>( const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
settings?.defaultBitrate ?? { key: "Max",
key: "Max", value: undefined,
value: undefined, });
}
);
const userCanDownload = useMemo( const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading, () => user?.Policy?.EnableContentDownloading,
@@ -164,9 +162,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
); );
} }
} else { } else {
toast.error( toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files"));
t("home.downloads.toasts.you_are_not_allowed_to_download_files")
);
} }
}, [ }, [
queue, queue,
@@ -198,11 +194,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
for (const item of items) { for (const item of items) {
if (itemsNotDownloaded.length > 1) { if (itemsNotDownloaded.length > 1) {
const defaults = getDefaultPlaySettings(item, settings!); ({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
mediaSource = defaults.mediaSource; item,
audioIndex = defaults.audioIndex; settings!
subtitleIndex = defaults.subtitleIndex; ));
// Keep using the selected bitrate for consistency across all downloads
} }
const res = await getStreamUrl({ const res = await getStreamUrl({
@@ -337,10 +332,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
{title} {title}
</Text> </Text>
<Text className="text-neutral-300"> <Text className="text-neutral-300">
{subtitle || {subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})}
t("item_card.download.download_x_item", {
item_count: itemsNotDownloaded.length,
})}
</Text> </Text>
</View> </View>
<View className="flex flex-col space-y-2 w-full items-start"> <View className="flex flex-col space-y-2 w-full items-start">
@@ -398,16 +390,12 @@ export const DownloadSingleItem: React.FC<{
size?: "default" | "large"; size?: "default" | "large";
item: BaseItemDto; item: BaseItemDto;
}> = ({ item, size = "default" }) => { }> = ({ item, size = "default" }) => {
if (Platform.isTV) return;
return ( return (
<DownloadItems <DownloadItems
size={size} size={size}
title={ title={item.Type == "Episode"
item.Type == "Episode" ? t("item_card.download.download_episode")
? t("item_card.download.download_episode") : t("item_card.download.download_movie")}
: t("item_card.download.download_movie")
}
subtitle={item.Name!} subtitle={item.Name!}
items={[item]} items={[item]}
MissingDownloadIconComponent={() => ( MissingDownloadIconComponent={() => (

View File

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

View File

@@ -15,8 +15,8 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors"; import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { import {
@@ -25,16 +25,18 @@ import {
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react"; import React, { lazy, useEffect, useMemo, useState } from "react";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AddToFavorites } from "./AddToFavorites"; // const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
const Chromecast = lazy(() => import("./Chromecast"));
import { ItemHeader } from "./ItemHeader"; import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null; import { AddToFavorites } from "./AddToFavorites";
export type SelectedOptions = { export type SelectedOptions = {
bitrate: Bitrate; bitrate: Bitrate;
@@ -87,18 +89,12 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
headerRight: () => headerRight: () =>
item && ( item && (
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
<Chromecast.Chromecast <Chromecast background="blur" width={22} height={22} />
background="blur"
width={22}
height={22}
/>
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
{!Platform.isTV && ( <DownloadSingleItem item={item} size="large" />
<DownloadSingleItem item={item} size="large" />
)}
<PlayedStatus items={[item]} size="large" /> <PlayedStatus items={[item]} size="large" />
<AddToFavorites item={item} /> <AddToFavorites item={item} type="item" />
</View> </View>
)} )}
</View> </View>
@@ -119,6 +115,37 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const loading = useMemo(() => { const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo); return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]); }, [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; if (!selectedOptions) return null;
return ( return (
@@ -166,6 +193,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
} }
> >
<View className="flex flex-col bg-transparent shrink"> <View className="flex flex-col bg-transparent shrink">
{/* {!Platform.isTV && ( */}
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink"> <View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<ItemHeader item={item} className="mb-4" /> <ItemHeader item={item} className="mb-4" />
{item.Type !== "Program" && !Platform.isTV && ( {item.Type !== "Program" && !Platform.isTV && (
@@ -208,6 +236,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
selected={selectedOptions.audioIndex} selected={selectedOptions.audioIndex}
/> />
<SubtitleTrackSelector <SubtitleTrackSelector
isTranscoding={isTranscoding}
source={selectedOptions.mediaSource} source={selectedOptions.mediaSource}
onChange={(val) => onChange={(val) =>
setSelectedOptions( setSelectedOptions(
@@ -223,11 +252,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View> </View>
)} )}
{/* {!Platform.isTV && ( */}
<PlayButton <PlayButton
className="grow" className="grow"
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
item={item} item={item}
/> />
{/* )} */}
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (

View File

@@ -16,7 +16,6 @@ import {
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { Button } from "./Button"; import { Button } from "./Button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatBitrate } from "@/utils/bitrate";
interface Props { interface Props {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -55,18 +54,14 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
<BottomSheetScrollView> <BottomSheetScrollView>
<View className="flex flex-col space-y-2 p-4 mb-4"> <View className="flex flex-col space-y-2 p-4 mb-4">
<View className=""> <View className="">
<Text className="text-lg font-bold mb-4"> <Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
{t("item_card.video")}
</Text>
<View className="flex flex-row space-x-2"> <View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} /> <VideoStreamInfo source={source} />
</View> </View>
</View> </View>
<View className=""> <View className="">
<Text className="text-lg font-bold mb-2"> <Text className="text-lg font-bold mb-2">{t("item_card.audio")}</Text>
{t("item_card.audio")}
</Text>
<AudioStreamInfo <AudioStreamInfo
audioStreams={ audioStreams={
source?.MediaStreams?.filter( source?.MediaStreams?.filter(
@@ -77,9 +72,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
</View> </View>
<View className=""> <View className="">
<Text className="text-lg font-bold mb-2"> <Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text>
{t("item_card.subtitles")}
</Text>
<SubtitleStreamInfo <SubtitleStreamInfo
subtitleStreams={ subtitleStreams={
source?.MediaStreams?.filter( source?.MediaStreams?.filter(
@@ -236,3 +229,12 @@ const formatFileSize = (bytes?: number | null) => {
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString()); const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i]; return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
}; };
const formatBitrate = (bitrate?: number | null) => {
if (!bitrate) return "N/A";
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
if (bitrate === 0) return "0 bps";
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
};

View File

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

View File

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

View File

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

View File

@@ -4,31 +4,40 @@ import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source?: MediaSourceInfo;
onChange: (value: number) => void; onChange: (value: number) => void;
selected?: number | undefined; selected?: number | undefined;
isTranscoding?: boolean;
} }
export const SubtitleTrackSelector: React.FC<Props> = ({ export const SubtitleTrackSelector: React.FC<Props> = ({
source, source,
onChange, onChange,
selected, selected,
isTranscoding,
...props ...props
}) => { }) => {
if (Platform.isTV) return null; if (Platform.isTV) return null;
const subtitleStreams = useMemo(() => { const subtitleStreams = useMemo(() => {
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle"); const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
}, [source]);
if (isTranscoding && Platform.OS === "ios") {
return subtitleHelper.getUniqueSubtitles();
}
return subtitleHelper.getSubtitles();
}, [source, isTranscoding]);
const selectedSubtitleSteam = useMemo( const selectedSubtitleSteam = useMemo(
() => subtitleStreams?.find((x) => x.Index === selected), () => subtitleStreams.find((x) => x.Index === selected),
[subtitleStreams, selected] [subtitleStreams, selected]
); );
if (subtitleStreams?.length === 0) return null; if (subtitleStreams.length === 0) return null;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -43,7 +52,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col " {...props}> <View className="flex flex-col " {...props}>
<Text numberOfLines={1} className="opacity-50 mb-1 text-xs"> <Text className="opacity-50 mb-1 text-xs">
{t("item_card.subtitles")} {t("item_card.subtitles")}
</Text> </Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
className="w-28 rounded-lg overflow-hidden border border-neutral-900" className="w-28 rounded-lg overflow-hidden border border-neutral-900"
id={item.id.toString()} id={item.id.toString()}
title={item.name} title={item.name}
colors={['transparent', 'transparent']} colors={[]}
contentFit={"cover"} contentFit={"cover"}
url={jellyseerrApi?.imageProxy( url={jellyseerrApi?.imageProxy(
item.backdrops?.[0], item.backdrops?.[0],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,20 @@
import { TrackInfo } from "@/modules/VlcPlayer.types"; import { TrackInfo } from "@/modules/vlc-player";
import React, { createContext, useContext, useState, ReactNode, useEffect, useMemo } from "react"; import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import React, {
createContext,
useContext,
useState,
ReactNode,
useEffect,
} from "react";
import { useControlContext } from "./ControlContext"; import { useControlContext } from "./ControlContext";
import { Track } from "../types";
import { router, useLocalSearchParams } from "expo-router";
interface VideoContextProps { interface VideoContextProps {
audioTracks: Track[] | null; audioTracks: TrackInfo[] | null;
subtitleTracks: Track[] | null; subtitleTracks: TrackInfo[] | null;
setAudioTrack: ((index: number) => void) | undefined; setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined; setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined; setSubtitleURL: ((url: string, customName: string) => void) | undefined;
@@ -16,8 +24,14 @@ const VideoContext = createContext<VideoContextProps | undefined>(undefined);
interface VideoProviderProps { interface VideoProviderProps {
children: ReactNode; children: ReactNode;
getAudioTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined; getAudioTracks:
getSubtitleTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined; | (() => Promise<TrackInfo[] | null>)
| (() => TrackInfo[])
| undefined;
getSubtitleTracks:
| (() => Promise<TrackInfo[] | null>)
| (() => TrackInfo[])
| undefined;
setAudioTrack: ((index: number) => void) | undefined; setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined; setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined; setSubtitleURL: ((url: string, customName: string) => void) | undefined;
@@ -31,135 +45,30 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
setSubtitleURL, setSubtitleURL,
setAudioTrack, setAudioTrack,
}) => { }) => {
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null); const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null); const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
null
);
const ControlContext = useControlContext(); const ControlContext = useControlContext();
const isVideoLoaded = ControlContext?.isVideoLoaded; 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(() => { useEffect(() => {
const fetchTracks = async () => { const fetchTracks = async () => {
if (getSubtitleTracks) { if (
const subtitleData = await getSubtitleTracks(); getSubtitleTracks &&
(subtitleTracks === null || subtitleTracks.length === 0)
// 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)); const subtitles = await getSubtitleTracks();
console.log("Getting embeded subtitles...", subtitles);
// 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); setSubtitleTracks(subtitles);
} }
if (getAudioTracks) { if (
const audioData = await getAudioTracks(); getAudioTracks &&
(audioTracks === null || audioTracks.length === 0)
const allAudio = mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || []; ) {
const audioTracks: Track[] = allAudio?.map((audio, idx) => { const audio = await getAudioTracks();
if (!mediaSource?.TranscodingUrl) { setAudioTracks(audio);
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(); fetchTracks();

View File

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

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

@@ -0,0 +1,228 @@
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,14 +13,7 @@ type ExternalSubtitle = {
type TranscodedSubtitle = { type TranscodedSubtitle = {
name: string; name: string;
index: number; index: number;
deliveryUrl: string;
IsTextSubtitleStream: boolean; IsTextSubtitleStream: boolean;
}; };
type Track = { export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle };
name: string;
index: number;
setTrack: () => void;
};
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };

View File

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

View File

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

View File

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

15
edge-to-edge-fix.patch Normal file
View File

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

View File

@@ -28,8 +28,8 @@ const useDefaultPlaySettings = (
(x) => x.Type === "Audio" (x) => x.Type === "Audio"
)?.Index; )?.Index;
// 4. Get default bitrate from settings or fallback to max // 4. Get default bitrate
const bitrate = settings?.defaultBitrate ?? BITRATES[0]; const bitrate = BITRATES[0];
return { return {
defaultAudioIndex: defaultAudioIndex:

View File

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

@@ -20,7 +20,7 @@ export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
} }
const createHapticHandler = useCallback( const createHapticHandler = useCallback(
(type: typeof Haptics.ImpactFeedbackStyle) => { (type: Haptics.ImpactFeedbackStyle) => {
return Platform.OS === "web" || Platform.isTV return Platform.OS === "web" || Platform.isTV
? () => {} ? () => {}
: () => Haptics.impactAsync(type); : () => Haptics.impactAsync(type);
@@ -28,7 +28,7 @@ export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
[] []
); );
const createNotificationFeedback = useCallback( const createNotificationFeedback = useCallback(
(type: typeof Haptics.NotificationFeedbackType) => { (type: Haptics.NotificationFeedbackType) => {
return Platform.OS === "web" || Platform.isTV return Platform.OS === "web" || Platform.isTV
? () => {} ? () => {}
: () => Haptics.notificationAsync(type); : () => Haptics.notificationAsync(type);

View File

@@ -70,7 +70,7 @@ export const useImageColors = ({
fallback: "#fff", fallback: "#fff",
cache: false, cache: false,
}) })
.then((colors: { platform: string; dominant: string; vibrant: string; detail: string; primary: string; }) => { .then((colors) => {
let primary: string = "#fff"; let primary: string = "#fff";
let text: string = "#000"; let text: string = "#000";
let backup: string = "#fff"; let backup: string = "#fff";
@@ -104,7 +104,7 @@ export const useImageColors = ({
storage.set(`${source.uri}-text`, text); storage.set(`${source.uri}-text`, text);
} }
}) })
.catch((error: any) => { .catch((error) => {
console.error("Error getting colors", error); console.error("Error getting colors", error);
}); });
} }

View File

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

View File

@@ -9,11 +9,8 @@ import {
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
// import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native"; // import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
const FFMPEGKitReactNative = !Platform.isTV const FFmpegKit = !Platform.isTV ? require("ffmpeg-kit-react-native") : null;
? require("ffmpeg-kit-react-native")
: null;
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback } from "react"; import { useCallback } from "react";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
@@ -25,11 +22,6 @@ import { JobStatus } from "@/utils/optimize-server";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession;
type Statistics = typeof FFMPEGKitReactNative.Statistics;
const FFmpegKit = Platform.isTV
? null
: (FFMPEGKitReactNative.FFmpegKit as typeof FFMPEGKitReactNative.FFmpegKit);
const createFFmpegCommand = (url: string, output: string) => [ const createFFmpegCommand = (url: string, output: string) => [
"-y", // overwrite output files without asking "-y", // overwrite output files without asking
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options "-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
@@ -104,11 +96,8 @@ export const useRemuxHlsToMp4 = () => {
toast.success(t("home.downloads.toasts.download_completed")); toast.success(t("home.downloads.toasts.download_completed"));
} }
setProcesses((prev: any[]) => { setProcesses((prev) => {
return prev.filter( return prev.filter((process) => process.itemId !== item.Id);
(process: { itemId: string | undefined }) =>
process.itemId !== item.Id
);
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -132,8 +121,8 @@ export const useRemuxHlsToMp4 = () => {
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0; totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
if (!item.Id) throw new Error("Item is undefined"); if (!item.Id) throw new Error("Item is undefined");
setProcesses((prev: any[]) => { setProcesses((prev) => {
return prev.map((process: { itemId: string | undefined }) => { return prev.map((process) => {
if (process.itemId === item.Id) { if (process.itemId === item.Id) {
return { return {
...process, ...process,
@@ -168,18 +157,15 @@ export const useRemuxHlsToMp4 = () => {
// First lets save any important assets we want to present to the user offline // First lets save any important assets we want to present to the user offline
await onSaveAssets(api, item); await onSaveAssets(api, item);
toast.success( toast.success(t("home.downloads.toasts.download_started_for", {item: item.Name}), {
t("home.downloads.toasts.download_started_for", { item: item.Name }), action: {
{ label: "Go to download",
action: { onClick: () => {
label: "Go to download", router.push("/downloads");
onClick: () => { toast.dismiss();
router.push("/downloads");
toast.dismiss();
},
}, },
} },
); });
try { try {
const job: JobStatus = { const job: JobStatus = {
@@ -195,13 +181,13 @@ export const useRemuxHlsToMp4 = () => {
}; };
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`); writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
setProcesses((prev: any) => [...prev, job]); setProcesses((prev) => [...prev, job]);
await FFmpegKit.executeAsync( await FFmpegKit.executeAsync(
createFFmpegCommand(url, output).join(" "), createFFmpegCommand(url, output).join(" "),
(session: any) => completeCallback(session, item), (session) => completeCallback(session, item),
undefined, undefined,
(s: any) => statisticsCallback(s, item) (s) => statisticsCallback(s, item)
); );
} catch (e) { } catch (e) {
const error = e as Error; const error = e as Error;
@@ -210,11 +196,8 @@ export const useRemuxHlsToMp4 = () => {
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
Error: ${error.message}, Stack: ${error.stack}` Error: ${error.message}, Stack: ${error.stack}`
); );
setProcesses((prev: any[]) => { setProcesses((prev) => {
return prev.filter( return prev.filter((process) => process.itemId !== item.Id);
(process: { itemId: string | undefined }) =>
process.itemId !== item.Id
);
}); });
throw error; // Re-throw the error to propagate it to the caller throw error; // Re-throw the error to propagate it to the caller
} }

View File

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

View File

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

View File

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

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

View File

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

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

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

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

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

View File

@@ -1,17 +1,12 @@
plugins { apply plugin: 'com.android.library'
id 'com.android.library' apply plugin: 'kotlin-android'
id 'kotlin-android' apply plugin: 'kotlin-kapt'
id 'kotlin-kapt'
}
group = 'expo.modules.vlcplayer' group = 'expo.modules.vlcplayer'
version = '0.6.0' version = '0.6.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
apply from: expoModulesCorePlugin apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin() applyKotlinExpoModulesCorePlugin()
useCoreDependencies() useCoreDependencies()
useExpoPublishing() useExpoPublishing()
@@ -42,8 +37,8 @@ if (useManagedAndroidSdkVersions) {
} }
dependencies { dependencies {
implementation 'org.videolan.android:libvlc-all:3.6.0' implementation 'org.videolan.android:libvlc-all:3.6.0-eap12'
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib:1.5.31"
} }
android { android {

View File

@@ -1,38 +0,0 @@
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,6 +1,5 @@
package expo.modules.vlcplayer package expo.modules.vlcplayer
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.modules.ModuleDefinition
@@ -8,18 +7,6 @@ class VlcPlayerModule : Module() {
override fun definition() = ModuleDefinition { override fun definition() = ModuleDefinition {
Name("VlcPlayer") Name("VlcPlayer")
OnActivityEntersForeground {
VLCManager.listeners.forEach {
it.onResume(appContext.currentActivity)
}
}
OnActivityEntersBackground {
VLCManager.listeners.forEach {
it.onPause(appContext.currentActivity)
}
}
View(VlcPlayerView::class) { View(VlcPlayerView::class) {
Prop("source") { view: VlcPlayerView, source: Map<String, Any> -> Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
view.setSource(source) view.setSource(source)
@@ -39,14 +26,9 @@ class VlcPlayerModule : Module() {
"onVideoLoadStart", "onVideoLoadStart",
"onVideoLoadEnd", "onVideoLoadEnd",
"onVideoProgress", "onVideoProgress",
"onVideoError", "onVideoError"
"onPipStarted"
) )
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
view.startPictureInPicture()
}
AsyncFunction("play") { view: VlcPlayerView -> AsyncFunction("play") { view: VlcPlayerView ->
view.play() view.play()
} }

View File

@@ -1,49 +1,23 @@
package expo.modules.vlcplayer 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
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
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.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.View import android.view.ViewGroup
import androidx.annotation.RequiresApi import android.widget.FrameLayout
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent import android.net.Uri
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.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView import expo.modules.kotlin.views.ExpoView
import expo.modules.kotlin.viewevent.EventDispatcher
import org.videolan.libvlc.LibVLC import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media import org.videolan.libvlc.Media
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.util.VLCVideoLayout 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"
private var libVLC: LibVLC? = null private var libVLC: LibVLC? = null
private var mediaPlayer: MediaPlayer? = null private var mediaPlayer: MediaPlayer? = null
@@ -52,12 +26,10 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var lastReportedState: Int? = null private var lastReportedState: Int? = null
private var lastReportedIsPlaying: Boolean? = null private var lastReportedIsPlaying: Boolean? = null
private var media : Media? = null private var media : Media? = null
private var timeLeft: Long? = null
private val onVideoProgress by EventDispatcher() private val onVideoProgress by EventDispatcher()
private val onVideoStateChange by EventDispatcher() private val onVideoStateChange by EventDispatcher()
private val onVideoLoadEnd by EventDispatcher() private val onVideoLoadEnd by EventDispatcher()
private val onPipStarted by EventDispatcher()
private var startPosition: Int? = 0 private var startPosition: Int? = 0
private var isMediaReady: Boolean = false private var isMediaReady: Boolean = false
@@ -72,146 +44,23 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
handler.postDelayed(this, updateInterval) handler.postDelayed(this, updateInterval)
} }
} }
private val currentActivity get() = context.findActivity()
private val actions: MutableList<RemoteAction> = mutableListOf()
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()
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)
}
}
}
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 { init {
VLCManager.listeners.add(this)
setupView() setupView()
setupPiP()
} }
private fun setupView() { private fun setupView() {
log.debug("Setting up view") Log.d("VlcPlayerView", "Setting up view")
setBackgroundColor(android.graphics.Color.WHITE) setBackgroundColor(android.graphics.Color.WHITE)
videoLayout = VLCVideoLayout(context).apply { videoLayout = VLCVideoLayout(context).apply {
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
} }
videoLayout.keepScreenOn = true
addView(videoLayout) addView(videoLayout)
log.debug("View setup complete") Log.d("VlcPlayerView", "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() {
actions.clear()
actions.addAll(
listOf(
RemoteAction(
Icon.createWithResource(context, R.drawable.ic_media_rew),
"Rewind",
"Rewind Video",
PendingIntent.getBroadcast(
context,
0,
rewindIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
)
),
RemoteAction(
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,
if (isPaused) 0 else 1,
playPauseIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
)
),
RemoteAction(
Icon.createWithResource(context, R.drawable.ic_media_ff),
"Skip",
"Skip Forward",
PendingIntent.getBroadcast(
context,
0,
forwardIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
)
)
)
)
}
private fun getPipParams(): PictureInPictureParams? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
var builder = PictureInPictureParams.Builder()
.setActions(actions)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder = builder.setAutoEnterEnabled(true)
}
return builder.build()
}
return null
} }
fun setSource(source: Map<String, Any>) { fun setSource(source: Map<String, Any>) {
log.debug("setting source $source")
if (hasSource) { if (hasSource) {
log.debug("Source already set. Resuming")
mediaPlayer?.attachViews(videoLayout, null, false, false) mediaPlayer?.attachViews(videoLayout, null, false, false)
play() play()
return return
@@ -236,12 +85,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
mediaPlayer?.attachViews(videoLayout, null, false, false) mediaPlayer?.attachViews(videoLayout, null, false, false)
mediaPlayer?.setEventListener(this) mediaPlayer?.setEventListener(this)
log.debug("Loading network file: $uri") Log.d("VlcPlayerView", "Loading network file: $uri")
media = Media(libVLC, Uri.parse(uri)) media = Media(libVLC, Uri.parse(uri))
mediaPlayer?.media = media mediaPlayer?.media = media
log.debug("Debug: Media options: $mediaOptions") Log.d("VlcPlayerView", "Debug: Media options: $mediaOptions")
// media.addOptions(mediaOptions) // media.addOptions(mediaOptions)
// Apply subtitle options // Apply subtitle options
@@ -258,17 +107,11 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
hasSource = true hasSource = true
if (autoplay) { if (autoplay) {
log.debug("Playing...") Log.d("VlcPlayerView", "Playing...")
play() play()
} }
} }
fun startPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
currentActivity.enterPictureInPictureMode(getPipParams()!!)
}
}
fun play() { fun play() {
mediaPlayer?.play() mediaPlayer?.play()
isPaused = false isPaused = false
@@ -308,7 +151,9 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
fun getAudioTracks(): List<Map<String, Any>>? { fun getAudioTracks(): List<Map<String, Any>>? {
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
println("getAudioTracks")
println(mediaPlayer?.getAudioTracks())
val trackDescriptions = mediaPlayer?.audioTracks ?: return null val trackDescriptions = mediaPlayer?.audioTracks ?: return null
return trackDescriptions.map { trackDescription -> return trackDescriptions.map { trackDescription ->
@@ -332,32 +177,19 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
// Debug statement to print the result // Debug statement to print the result
log.debug("Subtitle Tracks: $subtitleTracks") Log.d("VlcPlayerView", "Subtitle Tracks: $subtitleTracks")
return subtitleTracks return subtitleTracks
} }
fun setSubtitleURL(subtitleURL: String, name: String) { fun setSubtitleURL(subtitleURL: String, name: String) {
log.debug("Setting subtitle URL: $subtitleURL, name: $name") println("Setting subtitle URL: $subtitleURL, name: $name")
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true) mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
} }
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {
log.debug("onDetachedFromWindow") println("onDetachedFromWindow")
super.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() mediaPlayer?.stop()
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
@@ -370,7 +202,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
override fun onEvent(event: MediaPlayer.Event) { override fun onEvent(event: MediaPlayer.Event) {
keepScreenOn = event.type == MediaPlayer.Event.Playing || event.type == MediaPlayer.Event.Buffering
when (event.type) { when (event.type) {
MediaPlayer.Event.Playing, MediaPlayer.Event.Playing,
MediaPlayer.Event.Paused, MediaPlayer.Event.Paused,
@@ -392,27 +223,35 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
"target" to "null", // Replace with actual target if needed "target" to "null", // Replace with actual target if needed
"currentTime" to player.time.toInt(), "currentTime" to player.time.toInt(),
"duration" to (player.media?.duration?.toInt() ?: 0), "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) { when (currentState) {
MediaPlayer.Event.Playing -> MediaPlayer.Event.Playing -> {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing" stateInfo["state"] = "Playing"
MediaPlayer.Event.Paused -> }
MediaPlayer.Event.Paused -> {
stateInfo["isPlaying"] = false
stateInfo["state"] = "Paused" stateInfo["state"] = "Paused"
MediaPlayer.Event.Buffering -> }
MediaPlayer.Event.Buffering -> {
stateInfo["isBuffering"] = true
stateInfo["state"] = "Buffering" stateInfo["state"] = "Buffering"
}
MediaPlayer.Event.EncounteredError -> { MediaPlayer.Event.EncounteredError -> {
Log.e("VlcPlayerView", "player.state ~ error")
stateInfo["state"] = "Error" stateInfo["state"] = "Error"
onVideoLoadEnd(stateInfo); onVideoLoadEnd(stateInfo);
} }
MediaPlayer.Event.Opening -> MediaPlayer.Event.Opening -> {
Log.d("VlcPlayerView", "player.state ~ opening")
stateInfo["state"] = "Opening" stateInfo["state"] = "Opening"
}
} }
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) { if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
lastReportedState = currentState lastReportedState = currentState
lastReportedIsPlaying = player.isPlaying lastReportedIsPlaying = player.isPlaying
@@ -444,23 +283,4 @@ 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 {
var context = this
while (context is ContextWrapper) {
if (context is androidx.activity.ComponentActivity) return context
context = context.baseContext
}
throw IllegalStateException("Failed to find ComponentActivity")
} }

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