diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 82018c50..a38e5f84 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -4,9 +4,7 @@ title: "[Bug]: "
labels:
- ["❌ bug"]
projects:
- - ["fredrikburmester/5"]
-assignees:
- - fredrikburmester
+ - ["streamyfin/3"]
body:
- type: textarea
@@ -45,6 +43,8 @@ body:
label: Version
description: What version of Streamyfin are you running?
options:
+ - 0.25.0
+ - 0.24.0
- 0.23.0
- 0.22.0
- 0.21.0
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index 544b2743..0a4ed68b 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -4,7 +4,8 @@ about: Suggest an idea for this project
title: ''
labels: '✨ enhancement'
assignees: ''
-
+projects:
+ - streamyfin/3
---
**Describe the solution you'd like**
diff --git a/.github/workflows/build-ios.yaml b/.github/workflows/build-ios.yaml
new file mode 100644
index 00000000..354ecb00
--- /dev/null
+++ b/.github/workflows/build-ios.yaml
@@ -0,0 +1,49 @@
+name: Automatic Build and Deploy
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+
+jobs:
+ build:
+ runs-on: macos-15
+ name: Build IOS
+ steps:
+ - uses: actions/checkout@v2
+ name: Check out repository
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+ - run: |
+ bun i && bun run submodule-reload
+ npx expo prebuild
+ - uses: sparkfabrik/ios-build-action@v2.3.0
+ with:
+ upload-to-testflight: false
+ increment-build-number: false
+ build-pods: true
+ pods-path: "ios/Podfile"
+ configuration: Release
+ # Change later to app-store if wanted
+ export-method: appstore
+ #export-method: ad-hoc
+ workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
+ project-path: "ios/Streamyfin.xcodeproj"
+ scheme: Streamyfin
+ apple-key-id: ${{ secrets.APPLE_KEY_ID }}
+ apple-key-issuer-id: ${{ secrets.APPLE_KEY_ISSUER_ID }}
+ apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
+ team-id: ${{ secrets.TEAM_ID }}
+ team-name: ${{ secrets.TEAM_NAME }}
+ #match-password: ${{ secrets.MATCH_PASSWORD }}
+ #match-git-url: ${{ secrets.MATCH_GIT_URL }}
+ #match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
+ #match-build-type: "appstore"
+ #browserstack-upload: true
+ #browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
+ #browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
+ #fastlane-env: stage
+ ios-app-id: com.stetsed.teststreamyfin
+ output-path: build-${{ github.sha }}.ipa
diff --git a/.gitignore b/.gitignore
index 33ed8e6d..1878db42 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,4 +35,6 @@ credentials.json
*.ipa
.continuerc.json
-.vscode/
\ No newline at end of file
+.vscode/
+.idea/
+.ruby-lsp
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 26d33521..00000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml
deleted file mode 100644
index b81700b5..00000000
--- a/.idea/caches/deviceStreaming.xml
+++ /dev/null
@@ -1,329 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 639900d1..00000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index ba6d5c31..00000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/streamyfin.iml b/.idea/streamyfin.iml
deleted file mode 100644
index d6ebd480..00000000
--- a/.idea/streamyfin.iml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1ddf..00000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/README.md b/README.md
index 342daa48..533dd65f 100644
--- a/README.md
+++ b/README.md
@@ -8,12 +8,12 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
-
+
## 🌟 Features
-- 🚀 **Skp intro / credits support**
+- 🚀 **Skip Intro / Credits Support**
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
@@ -32,22 +32,17 @@ Downloading works by using ffmpeg to convert an HLS stream into a video file on
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
-## Plugins
+### Streamyfin Plugin
-In Streamyfin we have built-in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality.
+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:
-### Collection rows
+- Auto log in to Jellyseerr without the user having to do anythin
+- Choose the default languages
+- Set download method and search provider
+- Customize homescreen
+- And more...
-Jellyfin collections can be shown as rows or carousel on the home screen.
-The following tags can be added to a collection to provide this functionality.
-
-Available tags:
-
-- sf_promoted: will make the collection a row at home
-- sf_carousel: will make the collection a carousel on home.
-
-A plugin exists to create collections based on external sources like mdblist. This make the automatic process of managing collections such as trending, most watched, etc.
-See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info.
+[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
### Jellysearch
@@ -70,7 +65,7 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
### Beta testing
-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'll 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.
@@ -90,7 +85,7 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed.
-4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on you computer and run the app.
+4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on your computer and run the app.
## 📄 License
diff --git a/app.json b/app.json
index 59ffa419..666f2e48 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
- "version": "0.23.0",
+ "version": "0.25.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -36,7 +36,7 @@
},
"android": {
"jsEngine": "hermes",
- "versionCode": 49,
+ "versionCode": 50,
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png"
},
@@ -105,13 +105,16 @@
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
],
+ "expo-localization",
"expo-asset",
[
"react-native-edge-to-edge",
{ "android": { "parentTheme": "Material3" } }
],
["react-native-bottom-tabs"],
- ["./plugins/withChangeNativeAndroidTextToWhite.js"]
+ ["./plugins/withChangeNativeAndroidTextToWhite.js"],
+ ["./plugins/withGoogleCastActivity.js"],
+ ["./plugins/withTrustLocalCerts.js"]
],
"experiments": {
"typedRoutes": true
diff --git a/app/(auth)/(tabs)/(custom-links)/_layout.tsx b/app/(auth)/(tabs)/(custom-links)/_layout.tsx
index ed0529d4..c270b95d 100644
--- a/app/(auth)/(tabs)/(custom-links)/_layout.tsx
+++ b/app/(auth)/(tabs)/(custom-links)/_layout.tsx
@@ -1,7 +1,9 @@
import {Stack} from "expo-router";
import { Platform } from "react-native";
+import { useTranslation } from "react-i18next";
export default function CustomMenuLayout() {
+ const { t } = useTranslation();
return (
([]);
+ const { t } = useTranslation();
const getMenuLinks = useCallback(async () => {
try {
@@ -67,7 +69,7 @@ export default function menuLinks() {
)}
ListEmptyComponent={
- No links
+ {t("custom_links.no_links")}
}
/>
diff --git a/app/(auth)/(tabs)/(favorites)/_layout.tsx b/app/(auth)/(tabs)/(favorites)/_layout.tsx
index d48dc614..b408eab6 100644
--- a/app/(auth)/(tabs)/(favorites)/_layout.tsx
+++ b/app/(auth)/(tabs)/(favorites)/_layout.tsx
@@ -1,8 +1,10 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { Platform } from "react-native";
+import { useTranslation } from "react-i18next";
export default function SearchLayout() {
+ const { t } = useTranslation();
return (
+
+
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
))}
diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx
index 56f21af3..51991f1b 100644
--- a/app/(auth)/(tabs)/(home)/downloads/index.tsx
+++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx
@@ -4,7 +4,7 @@ import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
-import { useSettings } from "@/utils/atoms/settings";
+import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
@@ -12,6 +12,8 @@ import React, { useEffect, useMemo, useRef } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useTranslation } from "react-i18next";
+import { t } from 'i18next';
import { DownloadSize } from "@/components/downloads/DownloadSize";
import {
BottomSheetBackdrop,
@@ -24,6 +26,7 @@ import { writeToLog } from "@/utils/log";
export default function page() {
const navigation = useNavigation();
+ const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
const router = useRouter();
@@ -70,17 +73,17 @@ export default function page() {
const deleteMovies = () =>
deleteFileByType("Movie")
- .then(() => toast.success("Deleted all movies successfully!"))
+ .then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
.catch((reason) => {
writeToLog("ERROR", reason);
- toast.error("Failed to delete all movies");
+ toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
});
const deleteShows = () =>
deleteFileByType("Episode")
- .then(() => toast.success("Deleted all TV-Series successfully!"))
+ .then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
.catch((reason) => {
writeToLog("ERROR", reason);
- toast.error("Failed to delete all TV-Series");
+ toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
});
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows()]);
@@ -96,11 +99,11 @@ export default function page() {
>
- {settings?.downloadMethod === "remux" && (
+ {settings?.downloadMethod === DownloadMethod.Remux && (
- Queue
+ {t("home.downloads.queue")}
- Queue and active downloads will be lost on app restart
+ {t("home.downloads.queue_hint")}
{queue.map((q, index) => (
@@ -133,7 +136,7 @@ export default function page() {
{queue.length === 0 && (
- No items in queue
+ {t("home.downloads.no_items_in_queue")}
)}
)}
@@ -144,7 +147,7 @@ export default function page() {
{movies.length > 0 && (
- Movies
+ {t("home.downloads.movies")}
{movies?.length}
@@ -163,7 +166,7 @@ export default function page() {
{groupedBySeries.length > 0 && (
- TV-Series
+ {t("home.downloads.tvseries")}
{groupedBySeries?.length}
@@ -189,7 +192,7 @@ export default function page() {
)}
{downloadedFiles?.length === 0 && (
- No downloaded items
+ {t("home.downloads.no_downloaded_items")}
)}
@@ -214,13 +217,13 @@ export default function page() {
@@ -233,15 +236,15 @@ function migration_20241124() {
const router = useRouter();
const { deleteAllFiles } = useDownload();
Alert.alert(
- "New app version requires re-download",
- "The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
+ t("home.downloads.new_app_version_requires_re_download"),
+ t("home.downloads.new_app_version_requires_re_download_description"),
[
{
- text: "Back",
+ text: t("home.downloads.back"),
onPress: () => router.back(),
},
{
- text: "Delete",
+ text: t("home.downloads.delete"),
style: "destructive",
onPress: async () => await deleteAllFiles(),
},
diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx
index 0f777a45..fdbe0ea2 100644
--- a/app/(auth)/(tabs)/(home)/index.tsx
+++ b/app/(auth)/(tabs)/(home)/index.tsx
@@ -8,7 +8,7 @@ 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 { HomeSectionStyle, useSettings } from "@/utils/atoms/settings";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
@@ -23,10 +23,11 @@ import {
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
-import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
+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 { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
@@ -55,11 +56,19 @@ 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, _] = useSettings();
+ const [
+ settings,
+ updateSettings,
+ pluginSettings,
+ setPluginSettings,
+ refreshStreamyfinPluginSettings,
+ ] = useSettings();
const [isConnected, setIsConnected] = useState(null);
const [loadingRetry, setLoadingRetry] = useState(false);
@@ -110,13 +119,14 @@ export default function index() {
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
+
return () => {
unsubscribe();
};
}, []);
const {
- data: userViews,
+ data,
isError: e1,
isLoading: l1,
} = useQuery({
@@ -136,28 +146,10 @@ export default function index() {
staleTime: 60 * 1000,
});
- const {
- data: mediaListCollections,
- isError: e2,
- isLoading: l2,
- } = useQuery({
- queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
- queryFn: async () => {
- if (!api || !user?.Id) return [];
-
- const response = await getItemsApi(api).getItems({
- userId: user.Id,
- tags: ["sf_promoted"],
- recursive: true,
- fields: ["Tags"],
- includeItemTypes: ["BoxSet"],
- });
-
- return response.data.Items || [];
- },
- enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
- staleTime: 60 * 1000,
- });
+ const userViews = useMemo(
+ () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
+ [data, settings?.hiddenLibraries]
+ );
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
@@ -172,6 +164,7 @@ export default function index() {
const refetch = useCallback(async () => {
setLoading(true);
+ await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
}, []);
@@ -206,114 +199,160 @@ export default function index() {
[api, user?.Id]
);
- const sections = useMemo(() => {
- if (!api || !user?.Id) return [];
+ 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 = "Recently Added in " + c.Name;
- const queryKey = [
- "home",
- "recentlyAddedIn" + c.CollectionType,
- user?.Id!,
- c.Id!,
- ];
- return createCollectionConfig(
- title || "",
- queryKey,
- includeItemTypes,
- c.Id
- );
- });
+ 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: "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: "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: "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: "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 [];
- }
+ 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",
},
- type: "ScrollingCollectionList",
- orientation: "horizontal",
- },
- ];
- return ss;
- }, [api, user?.Id, collections, mediaListCollections]);
+ {
+ 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) {
+ 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 (
- No Internet
+ {t("home.no_internet")}
- No worries, you can still watch{"\n"}downloaded content.
+ {t("home.no_internet_message")}
}
>
- Go to downloads
+ {t("home.go_to_downloads")}
);
- if (l1 || l2)
+ if (l1)
return (
diff --git a/app/(auth)/(tabs)/(home)/intro/page.tsx b/app/(auth)/(tabs)/(home)/intro/page.tsx
new file mode 100644
index 00000000..7aae4ae2
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/intro/page.tsx
@@ -0,0 +1,135 @@
+import { Button } from "@/components/Button";
+import { Text } from "@/components/common/Text";
+import { storage } from "@/utils/mmkv";
+import { Feather, Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
+import { useFocusEffect, useRouter } from "expo-router";
+import { useCallback } from "react";
+import {useTranslation } from "react-i18next";
+import { Linking, TouchableOpacity, View } from "react-native";
+
+export default function page() {
+ const router = useRouter();
+ const { t } = useTranslation();
+
+ useFocusEffect(
+ useCallback(() => {
+ storage.set("hasShownIntro", true);
+ }, [])
+ );
+
+ return (
+
+
+
+ {t("home.intro.welcome_to_streamyfin")}
+
+
+ {t("home.intro.a_free_and_open_source_client_for_jellyfin")}
+
+
+
+
+ {t("home.intro.features_title")}
+
+ {t("home.intro.features_description")}
+
+
+
+
+ Jellyseerr
+
+ {t("home.intro.jellyseerr_feature_description")}
+
+
+
+
+
+
+
+
+ {t("home.intro.downloads_feature_title")}
+
+ {t("home.intro.downloads_feature_description")}
+
+
+
+
+
+
+
+
+ Chromecast
+
+ {t("home.intro.chromecast_feature_description")}
+
+
+
+
+
+
+
+
+ {t("home.intro.centralised_settings_plugin_title")}
+
+ {t("home.intro.centralised_settings_plugin_description")}{" "}
+ {
+ Linking.openURL(
+ "https://github.com/streamyfin/jellyfin-plugin-streamyfin"
+ );
+ }}
+ >
+ {t("home.intro.read_more")}
+
+
+
+
+
+
+
+ {
+ router.back();
+ router.push("/settings");
+ }}
+ className="mt-4"
+ >
+ {t("home.intro.go_to_settings_button")}
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx
index 8f6d102a..80a515dc 100644
--- a/app/(auth)/(tabs)/(home)/settings.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tsx
@@ -10,23 +10,27 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
+import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { UserInfo } from "@/components/settings/UserInfo";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import { useNavigation, useRouter } from "expo-router";
-import { useEffect } from "react";
+import { t } from "i18next";
+import React, { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { storage } from "@/utils/mmkv";
export default function settings() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { logout } = useJellyfin();
+ const successHapticFeedback = useHaptic("success");
const onClearLogsClicked = async () => {
clearLogs();
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ successHapticFeedback();
};
const navigation = useNavigation();
@@ -38,7 +42,7 @@ export default function settings() {
logout();
}}
>
- Log out
+ {t("home.settings.log_out_button")}
),
});
@@ -66,17 +70,35 @@ export default function settings() {
+
+
+
+ {
+ router.push("/intro/page");
+ }}
+ title={t("home.settings.intro.show_intro")}
+ />
+ {
+ storage.set("hasShownIntro", false);
+ }}
+ title={t("home.settings.intro.reset_intro")}
+ />
+
+
-
+
router.push("/settings/logs/page")}
showArrow
- title={"Logs"}
+ title={t("home.settings.logs.logs_title")}
/>
diff --git a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
new file mode 100644
index 00000000..5b96ddbc
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
@@ -0,0 +1,67 @@
+import { Text } from "@/components/common/Text";
+import { ListGroup } from "@/components/list/ListGroup";
+import { ListItem } from "@/components/list/ListItem";
+import { Loader } from "@/components/Loader";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { useAtomValue } from "jotai";
+import { Switch, View } from "react-native";
+import { useTranslation } from "react-i18next";
+import DisabledSetting from "@/components/settings/DisabledSetting";
+
+export default function page() {
+ const [settings, updateSettings, pluginSettings] = useSettings();
+ const user = useAtomValue(userAtom);
+ const api = useAtomValue(apiAtom);
+
+ const { t } = useTranslation();
+
+ const { data, isLoading: isLoading } = useQuery({
+ queryKey: ["user-views", user?.Id],
+ queryFn: async () => {
+ const response = await getUserViewsApi(api!).getUserViews({
+ userId: user?.Id,
+ });
+
+ return response.data.Items || null;
+ },
+ });
+
+ if (!settings) return null;
+
+ if (isLoading)
+ return (
+
+
+
+ );
+
+ return (
+
+
+ {data?.map((view) => (
+ {}}>
+ {
+ updateSettings({
+ hiddenLibraries: value
+ ? [...(settings.hiddenLibraries || []), view.Id!]
+ : settings.hiddenLibraries?.filter((id) => id !== view.Id),
+ });
+ }}
+ />
+
+ ))}
+
+
+ {t("home.settings.other.select_liraries_you_want_to_hide")}
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
index af4247d5..5da08ff1 100644
--- a/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
@@ -1,78 +1,16 @@
-import { Text } from "@/components/common/Text";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
-import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
-import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
-import { getOrSetDeviceId } from "@/utils/device";
-import { getStatistics } from "@/utils/optimize-server";
-import { useMutation } from "@tanstack/react-query";
-import { useNavigation } from "expo-router";
-import { useAtom } from "jotai";
-import { useEffect, useState } from "react";
-import { ActivityIndicator, TouchableOpacity, View } from "react-native";
-import { toast } from "sonner-native";
+import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
- const navigation = useNavigation();
-
- const [api] = useAtom(apiAtom);
- const [settings, updateSettings] = useSettings();
-
- const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
- useState(settings?.optimizedVersionsServerUrl || "");
-
- const saveMutation = useMutation({
- mutationFn: async (newVal: string) => {
- if (newVal.length === 0 || !newVal.startsWith("http")) {
- toast.error("Invalid URL");
- return;
- }
-
- const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
-
- updateSettings({
- optimizedVersionsServerUrl: updatedUrl,
- });
-
- return await getStatistics({
- url: settings?.optimizedVersionsServerUrl,
- authHeader: api?.accessToken,
- deviceId: getOrSetDeviceId(),
- });
- },
- onSuccess: (data) => {
- if (data) {
- toast.success("Connected");
- } else {
- toast.error("Could not connect");
- }
- },
- onError: () => {
- toast.error("Could not connect");
- },
- });
-
- const onSave = (newVal: string) => {
- saveMutation.mutate(newVal);
- };
-
- // useEffect(() => {
- // navigation.setOptions({
- // title: "Optimized Server",
- // headerRight: () =>
- // saveMutation.isPending ? (
- //
- // ) : (
- // onSave(optimizedVersionsServerUrl)}>
- // Save
- //
- // ),
- // });
- // }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
+ const [settings, updateSettings, pluginSettings] = useSettings();
return (
-
+
-
+
);
}
diff --git a/app/(auth)/(tabs)/(home)/settings/logs/page.tsx b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx
index 2e023c7d..1c59ba15 100644
--- a/app/(auth)/(tabs)/(home)/settings/logs/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx
@@ -1,9 +1,11 @@
import { Text } from "@/components/common/Text";
import { useLog } from "@/utils/log";
import { ScrollView, View } from "react-native";
+import { useTranslation } from "react-i18next";
export default function page() {
const { logs } = useLog();
+ const { t } = useTranslation();
return (
@@ -25,7 +27,7 @@ export default function page() {
))}
{logs?.length === 0 && (
- No logs available
+ {t("home.settings.logs.no_logs_available")}
)}
diff --git a/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx b/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
index b8255c6e..b67f6ea0 100644
--- a/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
@@ -1,12 +1,12 @@
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
-import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
-import { useAtom } from "jotai";
-import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import React, {useEffect, useMemo, useState} from "react";
import {
Linking,
Switch,
@@ -15,11 +15,14 @@ import {
View,
} from "react-native";
import { toast } from "sonner-native";
+import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
- const [settings, updateSettings] = useSettings();
+ const { t } = useTranslation();
+
+ const [settings, updateSettings, pluginSettings] = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState(settings?.marlinServerUrl || "");
@@ -28,76 +31,87 @@ export default function page() {
updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
});
- toast.success("Saved");
+ toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
};
const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
};
+ const disabled = useMemo(() => {
+ return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
+ }, [pluginSettings]);
+
useEffect(() => {
- navigation.setOptions({
- headerRight: () => (
- onSave(value)}>
- Save
-
- ),
- });
+ if (!pluginSettings?.marlinServerUrl?.locked) {
+ navigation.setOptions({
+ headerRight: () => (
+ onSave(value)}>
+ {t("home.settings.plugins.marlin_search.save_button")}
+
+ ),
+ });
+ }
}, [navigation, value]);
if (!settings) return null;
return (
-
+
- {
- updateSettings({ searchEngine: "Jellyfin" });
- queryClient.invalidateQueries({ queryKey: ["search"] });
- }}
+
- {
- updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
+ {
+ updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
- />
-
+ >
+ {
+ updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
+ queryClient.invalidateQueries({ queryKey: ["search"] });
+ }}
+ />
+
+
-
-
-
- URL
- setValue(text)}
- />
-
+
+ {t("home.settings.plugins.marlin_search.url")}
+ setValue(text)}
+ />
-
- Enter the URL for the Marlin server. The URL should include http or
- https and optionally the port.{" "}
-
- Read more about Marlin.
-
+
+
+ {t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
+
+ {t("home.settings.plugins.marlin_search.read_more_about_marlin")}
-
-
+
+
);
}
diff --git a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
index b47d565f..988651f0 100644
--- a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
@@ -10,12 +10,16 @@ import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
+import { useTranslation } from "react-i18next";
+import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
+ const { t } = useTranslation();
+
const [api] = useAtom(apiAtom);
- const [settings, updateSettings] = useSettings();
+ const [settings, updateSettings, pluginSettings] = useSettings();
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState(settings?.optimizedVersionsServerUrl || "");
@@ -23,7 +27,7 @@ export default function page() {
const saveMutation = useMutation({
mutationFn: async (newVal: string) => {
if (newVal.length === 0 || !newVal.startsWith("http")) {
- toast.error("Invalid URL");
+ toast.error(t("home.settings.toasts.invalid_url"));
return;
}
@@ -41,13 +45,13 @@ export default function page() {
},
onSuccess: (data) => {
if (data) {
- toast.success("Connected");
+ toast.success(t("home.settings.toasts.connected"));
} else {
- toast.error("Could not connect");
+ toast.error(t("home.settings.toasts.could_not_connect"));
}
},
onError: () => {
- toast.error("Could not connect");
+ toast.error(t("home.settings.toasts.could_not_connect"));
},
});
@@ -56,25 +60,30 @@ export default function page() {
};
useEffect(() => {
- navigation.setOptions({
- title: "Optimized Server",
- headerRight: () =>
- saveMutation.isPending ? (
-
- ) : (
- onSave(optimizedVersionsServerUrl)}>
- Save
-
- ),
- });
+ if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
+ navigation.setOptions({
+ title: t("home.settings.downloads.optimized_server"),
+ headerRight: () =>
+ saveMutation.isPending ? (
+
+ ) : (
+ onSave(optimizedVersionsServerUrl)}>
+ {t("home.settings.downloads.save_button")}
+
+ ),
+ });
+ }
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return (
-
+
-
+
);
}
diff --git a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx b/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx
deleted file mode 100644
index 43cf76c4..00000000
--- a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import { Text } from "@/components/common/Text";
-import { ListGroup } from "@/components/list/ListGroup";
-import { ListItem } from "@/components/list/ListItem";
-import { Loader } from "@/components/Loader";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { useSettings } from "@/utils/atoms/settings";
-import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { useNavigation } from "expo-router";
-import { useAtom } from "jotai";
-import { Linking, Switch, View } from "react-native";
-
-export default function page() {
- const navigation = useNavigation();
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const [settings, updateSettings] = useSettings();
-
- const handleOpenLink = () => {
- Linking.openURL(
- "https://github.com/lostb1t/jellyfin-plugin-collection-import"
- );
- };
-
- const queryClient = useQueryClient();
-
- const {
- data: mediaListCollections,
- isLoading: isLoadingMediaListCollections,
- } = useQuery({
- queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
- queryFn: async () => {
- if (!api || !user?.Id) return [];
-
- const response = await getItemsApi(api).getItems({
- userId: user.Id,
- tags: ["sf_promoted"],
- recursive: true,
- fields: ["Tags"],
- includeItemTypes: ["BoxSet"],
- });
-
- return response.data.Items ?? [];
- },
- enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
- staleTime: 0,
- });
-
- if (!settings) return null;
-
- return (
-
-
- {
- updateSettings({ usePopularPlugin: true });
- queryClient.invalidateQueries({ queryKey: ["search"] });
- }}
- >
- {
- updateSettings({ usePopularPlugin: value });
- }}
- />
-
-
-
- Popular Lists is a plugin that enables you to show custom Jellyfin lists
- on the Streamyfin home page.{" "}
-
- Read more about Popular Lists.
-
-
-
- {settings.usePopularPlugin && (
- <>
- {!isLoadingMediaListCollections ? (
- <>
- {mediaListCollections?.length === 0 ? (
-
- No collections found. Add some in Jellyfin.
-
- ) : (
- <>
-
- {mediaListCollections?.map((mlc) => (
-
- {
- if (!settings.mediaListCollectionIds) {
- updateSettings({
- mediaListCollectionIds: [mlc.Id!],
- });
- return;
- }
-
- updateSettings({
- mediaListCollectionIds:
- settings.mediaListCollectionIds.includes(
- mlc.Id!
- )
- ? settings.mediaListCollectionIds.filter(
- (id) => id !== mlc.Id
- )
- : [
- ...settings.mediaListCollectionIds,
- mlc.Id!,
- ],
- });
- }}
- />
-
- ))}
-
-
- Select the lists you want displayed on the home screen.
-
- >
- )}
- >
- ) : (
-
- )}
- >
- )}
-
- );
-}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/actors/[actorId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/actors/[actorId].tsx
index 45dc8a4d..d2c15c3d 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/actors/[actorId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/actors/[actorId].tsx
@@ -18,10 +18,12 @@ import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { View } from "react-native";
+import { useTranslation } from "react-i18next";
const page: React.FC = () => {
const local = useLocalSearchParams();
const { actorId } = local as { actorId: string };
+ const { t } = useTranslation();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -110,7 +112,7 @@ const page: React.FC = () => {
- Appeared In
+ {t("item_card.appeared_in")}
{
- navigation.setOptions({
- headerRight: () => (
-
-
-
- ),
- });
- });
-
- const { data: album } = useQuery({
- queryKey: ["album", albumId, artistId],
- queryFn: async () => {
- if (!api) return null;
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- ids: [albumId],
- });
- const data = response.data.Items?.[0];
- return data;
- },
- enabled: !!api && !!user?.Id && !!albumId,
- staleTime: 0,
- });
-
- const {
- data: songs,
- isLoading,
- isError,
- } = useQuery<{
- Items: BaseItemDto[];
- TotalRecordCount: number;
- }>({
- queryKey: ["songs", artistId, albumId],
- queryFn: async () => {
- if (!api)
- return {
- Items: [],
- TotalRecordCount: 0,
- };
-
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- parentId: albumId,
- fields: [
- "ItemCounts",
- "PrimaryImageAspectRatio",
- "CanDelete",
- "MediaSourceCount",
- ],
- sortBy: ["ParentIndexNumber", "IndexNumber", "SortName"],
- });
-
- const data = response.data.Items;
-
- return {
- Items: data || [],
- TotalRecordCount: response.data.TotalRecordCount || 0,
- };
- },
- enabled: !!api && !!user?.Id,
- });
-
- const insets = useSafeAreaInsets();
-
- if (!album) return null;
-
- return (
-
- }
- >
-
- {album?.Name}
-
- {songs?.TotalRecordCount} songs
-
-
-
-
-
-
- );
-}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx
deleted file mode 100644
index 8d82d205..00000000
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-import ArtistPoster from "@/components/posters/ArtistPoster";
-import { Text } from "@/components/common/Text";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
-import { router, useLocalSearchParams, useNavigation } from "expo-router";
-import { useAtom } from "jotai";
-import { useEffect, useState } from "react";
-import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { ItemImage } from "@/components/common/ItemImage";
-import { ParallaxScrollView } from "@/components/ParallaxPage";
-import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
-
-export default function page() {
- const searchParams = useLocalSearchParams();
- const { artistId } = searchParams as {
- artistId: string;
- };
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const navigation = useNavigation();
-
- const [startIndex, setStartIndex] = useState(0);
-
- const { data: artist } = useQuery({
- queryKey: ["album", artistId],
- queryFn: async () => {
- if (!api) return null;
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- ids: [artistId],
- });
- const data = response.data.Items?.[0];
- return data;
- },
- enabled: !!api && !!user?.Id && !!artistId,
- staleTime: 0,
- });
-
- const {
- data: albums,
- isLoading,
- isError,
- } = useQuery<{
- Items: BaseItemDto[];
- TotalRecordCount: number;
- }>({
- queryKey: ["albums", artistId, startIndex],
- queryFn: async () => {
- if (!api)
- return {
- Items: [],
- TotalRecordCount: 0,
- };
-
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- parentId: artistId,
- sortOrder: ["Descending", "Descending", "Ascending"],
- includeItemTypes: ["MusicAlbum"],
- recursive: true,
- fields: [
- "ParentId",
- "PrimaryImageAspectRatio",
- "ParentId",
- "PrimaryImageAspectRatio",
- ],
- collapseBoxSetItems: false,
- albumArtistIds: [artistId],
- startIndex,
- limit: 100,
- sortBy: ["PremiereDate", "ProductionYear", "SortName"],
- });
-
- const data = response.data.Items;
-
- return {
- Items: data || [],
- TotalRecordCount: response.data.TotalRecordCount || 0,
- };
- },
- enabled: !!api && !!user?.Id,
- });
-
- const insets = useSafeAreaInsets();
-
- if (!artist || !albums) return null;
-
- return (
-
- }
- >
-
- {artist?.Name}
-
- {albums.TotalRecordCount} albums
-
-
-
- {albums.Items.map((item, idx) => (
-
-
-
- {item.Name}
- {item.ProductionYear}
-
-
- ))}
-
-
- );
-}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx
deleted file mode 100644
index 4827287e..00000000
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import { Text } from "@/components/common/Text";
-import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
-import ArtistPoster from "@/components/posters/ArtistPoster";
-import MoviePoster from "@/components/posters/MoviePoster";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
-import { router, useLocalSearchParams } from "expo-router";
-import { useAtom } from "jotai";
-import { useMemo, useState } from "react";
-import { FlatList, TouchableOpacity, View } from "react-native";
-
-export default function page() {
- const searchParams = useLocalSearchParams();
- const { collectionId } = searchParams as { collectionId: string };
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const { data: collection } = useQuery({
- queryKey: ["collection", collectionId],
- queryFn: async () => {
- if (!api) return null;
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- ids: [collectionId],
- });
- const data = response.data.Items?.[0];
- return data;
- },
- enabled: !!api && !!user?.Id && !!collectionId,
- staleTime: 0,
- });
-
- const [startIndex, setStartIndex] = useState(0);
-
- const { data, isLoading, isError } = useQuery<{
- Items: BaseItemDto[];
- TotalRecordCount: number;
- }>({
- queryKey: ["collection-items", collection?.Id, startIndex],
- queryFn: async () => {
- if (!api || !collectionId)
- return {
- Items: [],
- TotalRecordCount: 0,
- };
-
- const response = await getArtistsApi(api).getArtists({
- sortBy: ["SortName"],
- sortOrder: ["Ascending"],
- fields: ["PrimaryImageAspectRatio", "SortName"],
- imageTypeLimit: 1,
- enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
- parentId: collectionId,
- userId: user?.Id,
- });
-
- const data = response.data.Items;
-
- return {
- Items: data || [],
- TotalRecordCount: response.data.TotalRecordCount || 0,
- };
- },
- enabled: !!collection?.Id && !!api && !!user?.Id,
- });
-
- const totalItems = useMemo(() => {
- return data?.TotalRecordCount;
- }, [data]);
-
- if (!data) return null;
-
- return (
-
- Artists
-
- }
- nestedScrollEnabled
- data={data.Items}
- numColumns={3}
- columnWrapperStyle={{
- justifyContent: "space-between",
- }}
- renderItem={({ item, index }) => (
-
-
- {collection?.CollectionType === "movies" && (
-
- )}
- {collection?.CollectionType === "music" && (
-
- )}
- {item.Name}
- {item.ProductionYear}
-
-
- )}
- keyExtractor={(item) => item.Id || ""}
- />
- );
-}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx
index 4c2b72ae..49896946 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx
@@ -33,6 +33,7 @@ import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FlatList, View } from "react-native";
+import { useTranslation } from "react-i18next";
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
@@ -45,6 +46,8 @@ const page: React.FC = () => {
ScreenOrientation.Orientation.PORTRAIT_UP
);
+ const { t } = useTranslation();
+
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
@@ -109,7 +112,7 @@ const page: React.FC = () => {
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
- includeItemTypes: ["Movie", "Series", "MusicAlbum"],
+ includeItemTypes: ["Movie", "Series"],
});
return response.data || null;
@@ -244,7 +247,7 @@ const page: React.FC = () => {
}}
set={setSelectedGenres}
values={selectedGenres}
- title="Genres"
+ title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
@@ -271,7 +274,7 @@ const page: React.FC = () => {
}}
set={setSelectedYears}
values={selectedYears}
- title="Years"
+ title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
@@ -296,7 +299,7 @@ const page: React.FC = () => {
}}
set={setSelectedTags}
values={selectedTags}
- title="Tags"
+ title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
@@ -314,7 +317,7 @@ const page: React.FC = () => {
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
- title="Sort By"
+ title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || ""
}
@@ -334,7 +337,7 @@ const page: React.FC = () => {
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
- title="Sort Order"
+ title={t("library.filters.sort_order")}
renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
@@ -374,7 +377,7 @@ const page: React.FC = () => {
- No results
+ {t("search.no_results")}
}
extraData={[
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx
index 38b0115d..a61114bd 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx
@@ -13,11 +13,13 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
+import { useTranslation } from "react-i18next";
const Page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { id } = useLocalSearchParams() as { id: string };
+ const { t } = useTranslation();
const { data: item, isError } = useQuery({
queryKey: ["item", id],
@@ -74,7 +76,7 @@ const Page: React.FC = () => {
if (isError)
return (
- Could not load item
+ {t("item_card.could_not_load_item")}
);
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx
new file mode 100644
index 00000000..c5eda557
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx
@@ -0,0 +1,95 @@
+import {router, useLocalSearchParams, useSegments,} from "expo-router";
+import React, {useMemo,} from "react";
+import {TouchableOpacity} from "react-native";
+import {useInfiniteQuery} from "@tanstack/react-query";
+import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
+import {Text} from "@/components/common/Text";
+import {Image} from "expo-image";
+import Poster from "@/components/posters/Poster";
+import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
+import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
+import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
+import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
+import {COMPANY_LOGO_IMAGE_FILTER} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
+import {uniqBy} from "lodash";
+import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
+
+export default function page() {
+ const local = useLocalSearchParams();
+ const {jellyseerrApi} = useJellyseerr();
+
+ const {companyId, name, image, type} = local as unknown as {
+ companyId: string,
+ name: string,
+ image: string,
+ type: DiscoverSliderType
+ };
+
+ const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
+ queryKey: ["jellyseerr", "company", type, companyId],
+ queryFn: async ({pageParam}) => {
+ let params: any = {
+ page: Number(pageParam),
+ };
+
+ return jellyseerrApi?.discover(
+ (
+ type == DiscoverSliderType.NETWORKS
+ ? Endpoints.DISCOVER_TV_NETWORK
+ : Endpoints.DISCOVER_MOVIES_STUDIO
+ ) + `/${companyId}`,
+ params
+ )
+ },
+ enabled: !!jellyseerrApi && !!companyId,
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, pages) =>
+ (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
+ 1,
+ staleTime: 0,
+ });
+
+ const flatData = useMemo(
+ () => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
+ [data]
+ );
+
+ const backdrops = useMemo(
+ () => jellyseerrApi
+ ? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
+ : [],
+ [jellyseerrApi, flatData]
+ );
+
+ return (
+ item.id.toString()}
+ onEndReached={() => {
+ if (hasNextPage) {
+ fetchNextPage()
+ }
+ }}
+ logo={
+
+ }
+ renderItem={(item, index) =>
+
+ }
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx
new file mode 100644
index 00000000..dbbce320
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx
@@ -0,0 +1,87 @@
+import {router, useLocalSearchParams, useSegments,} from "expo-router";
+import React, {useMemo,} from "react";
+import {TouchableOpacity} from "react-native";
+import {useInfiniteQuery} from "@tanstack/react-query";
+import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
+import {Text} from "@/components/common/Text";
+import Poster from "@/components/posters/Poster";
+import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
+import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
+import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
+import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
+import {uniqBy} from "lodash";
+import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
+import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
+
+export default function page() {
+ const local = useLocalSearchParams();
+ const {jellyseerrApi} = useJellyseerr();
+
+ const {genreId, name, type} = local as unknown as {
+ genreId: string,
+ name: string,
+ type: DiscoverSliderType
+ };
+
+ const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
+ queryKey: ["jellyseerr", "company", type, genreId],
+ queryFn: async ({pageParam}) => {
+ let params: any = {
+ page: Number(pageParam),
+ genre: genreId
+ };
+
+ return jellyseerrApi?.discover(
+ type == DiscoverSliderType.MOVIE_GENRES
+ ? Endpoints.DISCOVER_MOVIES
+ : Endpoints.DISCOVER_TV,
+ params
+ )
+ },
+ enabled: !!jellyseerrApi && !!genreId,
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, pages) =>
+ (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
+ 1,
+ staleTime: 0,
+ });
+
+ const flatData = useMemo(
+ () => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
+ [data]
+ );
+
+ const backdrops = useMemo(
+ () => jellyseerrApi
+ ? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
+ : [],
+ [jellyseerrApi, flatData]
+ );
+
+ return (
+ item.id.toString()}
+ onEndReached={() => {
+ if (hasNextPage) {
+ fetchNextPage()
+ }
+ }}
+ logo={
+
+ {name}
+
+ }
+ renderItem={(item, index) =>
+
+ }
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
index 42edcb59..3cf03a9a 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
@@ -1,61 +1,68 @@
-import React, { useCallback, useRef, useState } from "react";
-import { useLocalSearchParams } from "expo-router";
-import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
-import { Text } from "@/components/common/Text";
-import { ParallaxScrollView } from "@/components/ParallaxPage";
-import { Image } from "expo-image";
-import { TouchableOpacity, View} from "react-native";
-import { Ionicons } from "@expo/vector-icons";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { OverviewText } from "@/components/OverviewText";
-import { GenreTags } from "@/components/GenreTags";
-import { MediaType } from "@/utils/jellyseerr/server/constants/media";
-import { useQuery } from "@tanstack/react-query";
-import { useJellyseerr } from "@/hooks/useJellyseerr";
import { Button } from "@/components/Button";
-import {
- BottomSheetBackdrop,
- BottomSheetBackdropProps,
- BottomSheetModal, BottomSheetTextInput,
- BottomSheetView,
-} from "@gorhom/bottom-sheet";
+import { Text } from "@/components/common/Text";
+import { GenreTags } from "@/components/GenreTags";
+import Cast from "@/components/jellyseerr/Cast";
+import DetailFacts from "@/components/jellyseerr/DetailFacts";
+import { OverviewText } from "@/components/OverviewText";
+import { ParallaxScrollView } from "@/components/ParallaxPage";
+import { JellyserrRatings } from "@/components/Ratings";
+import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
+import { ItemActions } from "@/components/series/SeriesActions";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
-import * as DropdownMenu from "zeego/dropdown-menu";
+import { MediaType } from "@/utils/jellyseerr/server/constants/media";
+import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
-import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
-import { JellyserrRatings } from "@/components/Ratings";
+import { useTranslation } from "react-i18next";
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetTextInput,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import { useQuery } from "@tanstack/react-query";
+import { Image } from "expo-image";
+import { useLocalSearchParams, useNavigation } from "expo-router";
+import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
+import { TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import * as DropdownMenu from "zeego/dropdown-menu";
+import RequestModal from "@/components/jellyseerr/RequestModal";
+import {ANIME_KEYWORD_ID} from "@/utils/jellyseerr/server/api/themoviedb/constants";
+import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
- const {
- mediaTitle,
- releaseYear,
- canRequest: canRequestString,
- posterSrc,
- ...result
- } = params as unknown as {
- mediaTitle: string;
- releaseYear: number;
- canRequest: string;
- posterSrc: string;
- } & Partial;
+ const { t } = useTranslation();
- const canRequest = canRequestString === "true";
+ const { mediaTitle, releaseYear, posterSrc, ...result } =
+ params as unknown as {
+ mediaTitle: string;
+ releaseYear: number;
+ canRequest: string;
+ posterSrc: string;
+ } & Partial;
+
+ const navigation = useNavigation();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState();
const [issueMessage, setIssueMessage] = useState();
+ const advancedReqModalRef = useRef(null);
const bottomSheetModalRef = useRef(null);
const {
data: details,
isFetching,
isLoading,
- refetch
+ refetch,
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
@@ -72,6 +79,8 @@ const Page: React.FC = () => {
},
});
+ const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details);
+
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
{
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
- const request = useCallback(
- async () => {
- requestMedia(mediaTitle, {
- mediaId: Number(result.id!!),
- mediaType: result.mediaType!!,
- tvdbId: details?.externalIds?.tvdbId,
- seasons: (details as TvDetails)?.seasons
- ?.filter?.((s) => s.seasonNumber !== 0)
- ?.map?.((s) => s.seasonNumber),
- },
- refetch
- )
- },
- [details, result, requestMedia]
- );
+ const request = useCallback(async () => {
+ const body: MediaRequestBody = {
+ mediaId: Number(result.id!!),
+ mediaType: result.mediaType!!,
+ tvdbId: details?.externalIds?.tvdbId,
+ seasons: (details as TvDetails)?.seasons
+ ?.filter?.((s) => s.seasonNumber !== 0)
+ ?.map?.((s) => s.seasonNumber),
+ }
+
+ if (hasAdvancedRequestPermission) {
+ advancedReqModalRef?.current?.present?.(body)
+ return
+ }
+
+ requestMedia(mediaTitle, body, refetch);
+ }, [details, result, requestMedia, hasAdvancedRequestPermission]);
+
+ const isAnime = useMemo(
+ () => (details?.keywords.some(k => k.id === ANIME_KEYWORD_ID) || false) && result.mediaType === MediaType.TV,
+ [details]
+ )
+
+ useEffect(() => {
+ if (details) {
+ navigation.setOptions({
+ headerRight: () => (
+
+
+
+ ),
+ });
+ }
+ }, [details]);
return (
{
height: "100%",
}}
source={{
- uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
+ uri: jellyseerrApi?.imageProxy(
+ result.backdropPath,
+ "w1920_and_h800_multi_faces"
+ ),
}}
/>
) : (
@@ -182,9 +213,11 @@ const Page: React.FC = () => {
g.name) || []} />
- {canRequest ? (
+ {isLoading || isFetching ? (
+
+ ) : canRequest ? (
) : (
)}
@@ -211,11 +244,31 @@ const Page: React.FC = () => {
result={result as TvResult}
details={details as TvDetails}
refetch={refetch}
+ hasAdvancedRequest={hasAdvancedRequestPermission}
+ onAdvancedRequest={(data) =>
+ advancedReqModalRef?.current?.present(data)
+ }
/>
)}
+
+
+ {
+ advancedReqModalRef?.current?.close()
+ refetch()
+ }}
+ />
{
- Whats wrong?
+ {t("jellyseerr.whats_wrong")}
@@ -240,13 +293,13 @@ const Page: React.FC = () => {
- Issue Type
+ {t("jellyseerr.issue_type")}
{issueType
? IssueTypeName[issueType]
- : "Select an issue"}
+ : t("jellyseerr.select_an_issue")}
@@ -260,7 +313,7 @@ const Page: React.FC = () => {
collisionPadding={0}
sideOffset={0}
>
- Types
+ {t("jellyseerr.types")}
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], idx) => (
@@ -279,15 +332,13 @@ const Page: React.FC = () => {
-
+
{
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx
new file mode 100644
index 00000000..f152563a
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx
@@ -0,0 +1,110 @@
+import {
+ useLocalSearchParams,
+ useSegments,
+} from "expo-router";
+import React, { useMemo } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { Text } from "@/components/common/Text";
+import { Image } from "expo-image";
+import { OverviewText } from "@/components/OverviewText";
+import {orderBy, uniqBy} from "lodash";
+import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
+import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
+import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
+import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
+import { useTranslation } from "react-i18next";
+
+export default function page() {
+ const local = useLocalSearchParams();
+ const { t } = useTranslation();
+
+ const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
+
+ const { personId } = local as { personId: string };
+
+ const { data, isLoading, isFetching } = useQuery({
+ queryKey: ["jellyseerr", "person", personId],
+ queryFn: async () => ({
+ details: await jellyseerrApi?.personDetails(personId),
+ combinedCredits: await jellyseerrApi?.personCombinedCredits(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(
+ () =>
+ uniqBy(orderBy(
+ data?.combinedCredits?.cast,
+ ["voteCount", "voteAverage"],
+ "desc"
+ ), 'id'),
+ [data?.combinedCredits]
+ );
+ const backdrops = useMemo(
+ () => jellyseerrApi
+ ? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"))
+ : [],
+ [jellyseerrApi, data?.combinedCredits]
+ );
+
+ return (
+ item.id.toString()}
+ logo={
+
+ }
+ HeaderContent={() => (
+ <>
+
+ {data?.details?.name}
+
+
+ {t("jellyseerr.born")}{" "}
+ {new Date(data?.details?.birthday!!).toLocaleDateString(
+ `${locale}-${region}`,
+ {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }
+ )}{" "}
+ | {data?.details?.placeOfBirth}
+
+ >
+ )}
+ MainContent={() => (
+
+ )}
+ renderItem={(item, index) => }
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx
index 01652b5f..398d74b6 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx
@@ -17,6 +17,7 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useTranslation } from "react-i18next";
const HOUR_HEIGHT = 30;
const ITEMS_PER_PAGE = 20;
@@ -177,6 +178,7 @@ const PageButtons: React.FC = ({
onNextPage,
isNextDisabled,
}) => {
+ const { t } = useTranslation();
return (
= ({
currentPage === 1 ? "text-gray-500" : "text-white"
}`}
>
- Previous
+ {t("live_tv.previous")}
Page {currentPage}
@@ -206,7 +208,7 @@ const PageButtons: React.FC = ({
- Next
+ {t("live_tv.next")}
{
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getRecommendedPrograms({
@@ -46,7 +49,7 @@ export default function page() {
/>
{
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -68,7 +71,7 @@ export default function page() {
/>
{
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -86,7 +89,7 @@ export default function page() {
/>
{
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -104,7 +107,7 @@ export default function page() {
/>
{
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -122,7 +125,7 @@ export default function page() {
/>
{
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx
index 6e3f660e..4068f8a3 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx
@@ -1,11 +1,13 @@
import { Text } from "@/components/common/Text";
import React from "react";
import { View } from "react-native";
+import { useTranslation } from "react-i18next";
export default function page() {
+ const { t } = useTranslation();
return (
- Coming soon
+ {t("live_tv.coming_soon")}
);
}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx
index 2758010b..a62405e1 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx
@@ -16,9 +16,11 @@ import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useMemo } from "react";
import { View } from "react-native";
+import { useTranslation } from "react-i18next";
const page: React.FC = () => {
const navigation = useNavigation();
+ const { t } = useTranslation();
const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as {
id: string;
@@ -85,7 +87,7 @@ const page: React.FC = () => {
(
diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
index 7c0dbc91..15c9aa52 100644
--- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
+++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
@@ -41,6 +41,7 @@ import {
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useTranslation } from "react-i18next";
const Page = () => {
const searchParams = useLocalSearchParams();
@@ -62,6 +63,8 @@ const Page = () => {
const { orientation } = useOrientation();
+ const { t } = useTranslation();
+
useEffect(() => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sop) {
@@ -150,8 +153,6 @@ const Page = () => {
itemType = "Series";
} else if (library.CollectionType === "boxsets") {
itemType = "BoxSet";
- } else if (library.CollectionType === "music") {
- itemType = "MusicAlbum";
}
const response = await getItemsApi(api).getItems({
@@ -300,7 +301,7 @@ const Page = () => {
}}
set={setSelectedGenres}
values={selectedGenres}
- title="Genres"
+ title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
@@ -327,7 +328,7 @@ const Page = () => {
}}
set={setSelectedYears}
values={selectedYears}
- title="Years"
+ title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
@@ -352,7 +353,7 @@ const Page = () => {
}}
set={setSelectedTags}
values={selectedTags}
- title="Tags"
+ title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
@@ -370,7 +371,7 @@ const Page = () => {
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
- title="Sort By"
+ title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || ""
}
@@ -390,7 +391,7 @@ const Page = () => {
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
- title="Sort Order"
+ title={t("library.filters.sort_order")}
renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
@@ -436,7 +437,7 @@ const Page = () => {
if (flatData.length === 0)
return (
- No items found
+ {t("library.no_items_found")}
);
@@ -445,7 +446,7 @@ const Page = () => {
key={orientation}
ListEmptyComponent={
- No results
+ {t("library.no_results")}
}
contentInsetAdjustmentBehavior="automatic"
diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx
index 17813ed1..5cce9784 100644
--- a/app/(auth)/(tabs)/(libraries)/_layout.tsx
+++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx
@@ -4,9 +4,12 @@ import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router";
import { Platform } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
+import { useTranslation } from "react-i18next";
export default function IndexLayout() {
- const [settings, updateSettings] = useSettings();
+ const [settings, updateSettings, pluginSettings] = useSettings();
+
+ const { t } = useTranslation();
if (!settings?.libraryOptions) return null;
@@ -17,7 +20,7 @@ export default function IndexLayout() {
options={{
headerShown: true,
headerLargeTitle: true,
- headerTitle: "Library",
+ headerTitle: t("tabs.library"),
headerBlurEffect: "prominent",
headerLargeStyle: {
backgroundColor: "black",
@@ -25,6 +28,7 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerRight: () => (
+ !pluginSettings?.libraryOptions?.locked &&
- Display
+ {t("library.options.display")}
- Display
+ {t("library.options.display")}
- Row
+ {t("library.options.row")}
- List
+ {t("library.options.list")}
- Image style
+ {t("library.options.image_style")}
- Poster
+ {t("library.options.poster")}
- Cover
+ {t("library.options.cover")}
@@ -157,7 +161,7 @@ export default function IndexLayout() {
>
- Show titles
+ {t("library.options.show_titles")}
- Show stats
+ {t("library.options.show_stats")}
diff --git a/app/(auth)/(tabs)/(libraries)/index.tsx b/app/(auth)/(tabs)/(libraries)/index.tsx
index ef729254..ba11cc45 100644
--- a/app/(auth)/(tabs)/(libraries)/index.tsx
+++ b/app/(auth)/(tabs)/(libraries)/index.tsx
@@ -10,9 +10,10 @@ import {
import { FlashList } from "@shopify/flash-list";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useTranslation } from "react-i18next";
export default function index() {
const [api] = useAtom(apiAtom);
@@ -20,23 +21,29 @@ export default function index() {
const queryClient = useQueryClient();
const [settings] = useSettings();
+ const { t } = useTranslation();
+
const { data, isLoading: isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
- if (!api || !user?.Id) {
- return null;
- }
-
- const response = await getUserViewsApi(api).getUserViews({
- userId: user.Id,
+ const response = await getUserViewsApi(api!).getUserViews({
+ userId: user?.Id,
});
return response.data.Items || null;
},
- enabled: !!api && !!user?.Id,
- staleTime: 60 * 1000 * 60,
+ staleTime: 60,
});
+ const libraries = useMemo(
+ () =>
+ data
+ ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
+ .filter((l) => l.CollectionType !== "music")
+ .filter((l) => l.CollectionType !== "books") || [],
+ [data, settings?.hiddenLibraries]
+ );
+
useEffect(() => {
for (const item of data || []) {
queryClient.prefetchQuery({
@@ -63,10 +70,10 @@ export default function index() {
);
- if (!data)
+ if (!libraries)
return (
- No libraries found
+ {t("library.no_libraries_found")}
);
@@ -81,7 +88,7 @@ export default function index() {
paddingLeft: insets.left,
paddingRight: insets.right,
}}
- data={data}
+ data={libraries}
renderItem={({ item }) => }
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() =>
diff --git a/app/(auth)/(tabs)/(search)/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx
index 12cbad20..b031908e 100644
--- a/app/(auth)/(tabs)/(search)/_layout.tsx
+++ b/app/(auth)/(tabs)/(search)/_layout.tsx
@@ -4,8 +4,10 @@ import {
} from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { Platform } from "react-native";
+import { useTranslation } from "react-i18next";
export default function SearchLayout() {
+ const { t } = useTranslation();
return (
+
+
+
);
}
diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx
index 7d9ecebe..ccb90cb6 100644
--- a/app/(auth)/(tabs)/(search)/index.tsx
+++ b/app/(auth)/(tabs)/(search)/index.tsx
@@ -2,14 +2,16 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
+import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText";
-import { Loader } from "@/components/Loader";
-import AlbumCover from "@/components/posters/AlbumCover";
+import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
+import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
+import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
-import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import {
BaseItemDto,
BaseItemKind,
@@ -20,7 +22,6 @@ import axios from "axios";
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
- PropsWithChildren,
useCallback,
useEffect,
useLayoutEffect,
@@ -30,13 +31,7 @@ import React, {
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
-import { useJellyseerr } from "@/hooks/useJellyseerr";
-import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
-import { MediaType } from "@/utils/jellyseerr/server/constants/media";
-import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
-import { Tag } from "@/components/GenreTags";
-import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
-import { sortBy } from "lodash";
+import { useTranslation } from "react-i18next";
type SearchType = "Library" | "Discover";
@@ -53,6 +48,8 @@ export default function search() {
const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+
const { q, prev } = params as { q: string; prev: Href };
const [searchType, setSearchType] = useState("Library");
@@ -128,7 +125,7 @@ export default function search() {
if (Platform.OS === "ios")
navigation.setOptions({
headerSearchBarOptions: {
- placeholder: "Search...",
+ placeholder: t("search.search"),
onChangeText: (e: any) => {
router.setParams({ q: "" });
setSearch(e.nativeEvent.text);
@@ -149,48 +146,6 @@ export default function search() {
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
- const { data: jellyseerrResults, isFetching: j1 } = useQuery({
- queryKey: ["search", "jellyseerrResults", debouncedSearch],
- queryFn: async () => {
- const response = await jellyseerrApi?.search({
- query: new URLSearchParams(debouncedSearch).toString(),
- page: 1, // todo: maybe rework page & page-size if first results are not enough...
- language: "en",
- });
-
- return response?.results;
- },
- enabled:
- !!jellyseerrApi &&
- searchType === "Discover" &&
- debouncedSearch.length > 0,
- });
-
- const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
- queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
- queryFn: async () => jellyseerrApi?.discoverSettings(),
- enabled:
- !!jellyseerrApi &&
- searchType === "Discover" &&
- debouncedSearch.length == 0,
- });
-
- const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
- () =>
- jellyseerrResults?.filter(
- (r) => r.mediaType === MediaType.MOVIE
- ) as MovieResult[],
- [jellyseerrResults]
- );
-
- const jellyseerrTvResults: TvResult[] | undefined = useMemo(
- () =>
- jellyseerrResults?.filter(
- (r) => r.mediaType === MediaType.TV
- ) as TvResult[],
- [jellyseerrResults]
- );
-
const { data: series, isFetching: l2 } = useQuery({
queryKey: ["search", "series", debouncedSearch],
queryFn: () =>
@@ -231,64 +186,19 @@ export default function search() {
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
- const { data: artists, isFetching: l4 } = useQuery({
- queryKey: ["search", "artists", debouncedSearch],
- queryFn: () =>
- searchFn({
- query: debouncedSearch,
- types: ["MusicArtist"],
- }),
- enabled: searchType === "Library" && debouncedSearch.length > 0,
- });
-
- const { data: albums, isFetching: l5 } = useQuery({
- queryKey: ["search", "albums", debouncedSearch],
- queryFn: () =>
- searchFn({
- query: debouncedSearch,
- types: ["MusicAlbum"],
- }),
- enabled: searchType === "Library" && debouncedSearch.length > 0,
- });
-
- const { data: songs, isFetching: l6 } = useQuery({
- queryKey: ["search", "songs", debouncedSearch],
- queryFn: () =>
- searchFn({
- query: debouncedSearch,
- types: ["Audio"],
- }),
- enabled: searchType === "Library" && debouncedSearch.length > 0,
- });
-
const noResults = useMemo(() => {
return !(
- artists?.length ||
- albums?.length ||
- songs?.length ||
movies?.length ||
episodes?.length ||
series?.length ||
collections?.length ||
- actors?.length ||
- jellyseerrMovieResults?.length ||
- jellyseerrTvResults?.length
+ actors?.length
);
- }, [
- artists,
- episodes,
- albums,
- songs,
- movies,
- series,
- collections,
- actors,
- jellyseerrResults,
- ]);
+ }, [episodes, movies, series, collections, actors]);
const loading = useMemo(() => {
- return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
- }, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]);
+ return l1 || l2 || l3 || l7 || l8;
+ }, [l1, l2, l3, l7, l8]);
return (
<>
@@ -300,14 +210,14 @@ export default function search() {
paddingRight: insets.right,
}}
>
-
+
{Platform.OS === "android" && (
setSearch(text)}
/>
@@ -317,7 +227,7 @@ export default function search() {
setSearchType("Library")}>
setSearchType("Discover")}>
)}
- {!!q && (
-
-
- Results for {q}
-
-
- )}
- {searchType === "Library" && (
- <>
+
+
+
+
+
+ {searchType === "Library" ? (
+
m.Id!)}
renderItem={(item: BaseItemDto) => (
m.Id!)}
- header="Series"
+ header={t("search.series")}
renderItem={(item: BaseItemDto) => (
m.Id!)}
- header="Episodes"
+ header={t("search.episodes")}
renderItem={(item: BaseItemDto) => (
m.Id!)}
- header="Collections"
+ header={t("search.collections")}
renderItem={(item: BaseItemDto) => (
m.Id!)}
- header="Actors"
+ header={t("search.actors")}
renderItem={(item: BaseItemDto) => (
)}
/>
- m.Id!)}
- header="Artists"
- renderItem={(item: BaseItemDto) => (
-
-
-
-
- )}
- />
- m.Id!)}
- header="Albums"
- renderItem={(item: BaseItemDto) => (
-
-
-
-
- )}
- />
- m.Id!)}
- header="Songs"
- renderItem={(item: BaseItemDto) => (
-
-
-
-
- )}
- />
- >
- )}
- {searchType === "Discover" && (
- <>
- (
-
- )}
- />
- (
-
- )}
- />
- >
+
+ ) : (
+
)}
- {loading ? (
-
-
-
- ) : noResults && debouncedSearch.length > 0 ? (
-
-
- No results found for
-
-
- "{debouncedSearch}"
-
-
- ) : debouncedSearch.length === 0 && searchType === "Library" ? (
-
- {exampleSearches.map((e) => (
- setSearch(e)}
- key={e}
- className="mb-2"
- >
- {e}
-
- ))}
-
- ) : debouncedSearch.length === 0 && searchType === "Discover" ? (
-
- {sortBy?.(
- jellyseerrDiscoverSettings?.filter((s) => s.enabled),
- "order"
- ).map((slide) => (
-
- ))}
-
- ) : null}
+ {searchType === "Library" && (
+ <>
+ {!loading && noResults && debouncedSearch.length > 0 ? (
+
+
+ {t("search.no_results_found_for")}
+
+
+ "{debouncedSearch}"
+
+
+ ) : debouncedSearch.length === 0 ? (
+
+ {exampleSearches.map((e) => (
+ setSearch(e)}
+ key={e}
+ className="mb-2"
+ >
+ {e}
+
+ ))}
+
+ ) : null}
+ >
+ )}
>
);
}
-
-type Props = {
- ids?: string[] | null;
- items?: T[];
- renderItem: (item: any) => React.ReactNode;
- header?: string;
-};
-
-const SearchItemWrapper = ({
- ids,
- items,
- renderItem,
- header,
-}: PropsWithChildren>) => {
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const { data, isLoading: l1 } = useQuery({
- queryKey: ["items", ids],
- queryFn: async () => {
- if (!user?.Id || !api || !ids || ids.length === 0) {
- return [];
- }
-
- const itemPromises = ids.map((id) =>
- getUserItemData({
- api,
- userId: user.Id,
- itemId: id,
- })
- );
-
- const results = await Promise.all(itemPromises);
-
- // Filter out null items
- return results.filter(
- (item) => item !== null
- ) as unknown as BaseItemDto[];
- },
- enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
- staleTime: Infinity,
- });
-
- if (!data && (!items || items.length === 0)) return null;
-
- return (
- <>
- {header}
-
- {data && data?.length > 0
- ? data.map((item) => renderItem(item))
- : items && items?.length > 0
- ? items.map((i) => renderItem(i))
- : undefined}
-
- >
- );
-};
diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx
index 47e5bfaa..ade003ff 100644
--- a/app/(auth)/(tabs)/_layout.tsx
+++ b/app/(auth)/(tabs)/_layout.tsx
@@ -1,7 +1,8 @@
-import React from "react";
+import React, { useCallback, useRef } from "react";
import { Platform } from "react-native";
+import { useTranslation } from "react-i18next";
-import { withLayoutContext } from "expo-router";
+import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import {
createNativeBottomTabNavigator,
@@ -13,12 +14,13 @@ const { Navigator } = createNativeBottomTabNavigator();
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors";
+import { useSettings } from "@/utils/atoms/settings";
+import { storage } from "@/utils/mmkv";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge";
-import { useSettings } from "@/utils/atoms/settings";
export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions,
@@ -29,11 +31,29 @@ export const NativeTabs = withLayoutContext<
export default function TabLayout() {
const [settings] = useSettings();
+ const { t } = useTranslation();
+ const router = useRouter();
+
+ useFocusEffect(
+ useCallback(() => {
+ const hasShownIntro = storage.getBoolean("hasShownIntro");
+ if (!hasShownIntro) {
+ const timer = setTimeout(() => {
+ router.push("/intro/page");
+ }, 1000);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }
+ }, [])
+ );
+
return (
<>
@@ -57,7 +77,7 @@ export default function TabLayout() {
@@ -71,7 +91,7 @@ export default function TabLayout() {
@@ -87,7 +107,7 @@ export default function TabLayout() {
@@ -101,7 +121,7 @@ export default function TabLayout() {
-
>
);
diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx
index 5b6d086d..03b29cc3 100644
--- a/app/(auth)/player/direct-player.tsx
+++ b/app/(auth)/player/direct-player.tsx
@@ -27,7 +27,7 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
@@ -48,11 +48,14 @@ import {
import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings";
+import { useTranslation } from "react-i18next";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const videoRef = useRef(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
+ const { t } = useTranslation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
@@ -68,9 +71,11 @@ export default function page() {
const { getDownloadedItem } = useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
+ const lightHapticFeedback = useHaptic("light");
+
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
}, []);
const {
@@ -158,7 +163,7 @@ export default function page() {
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
- Alert.alert("Error", "Failed to get stream url");
+ Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
return null;
}
@@ -175,7 +180,7 @@ export default function page() {
const togglePlay = useCallback(async () => {
if (!api) return;
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
if (isPlaying) {
await videoRef.current?.pause();
@@ -411,6 +416,8 @@ export default function page() {
}
}
+ const insets = useSafeAreaInsets();
+
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
return (
@@ -421,7 +428,7 @@ export default function page() {
if (isErrorItem || isErrorStreamUrl)
return (
- Error
+ {t("player.error")}
);
@@ -435,7 +442,8 @@ export default function page() {
position: "relative",
flexDirection: "column",
justifyContent: "center",
- opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
+ paddingLeft: ignoreSafeAreas ? 0 : insets.left,
+ paddingRight: ignoreSafeAreas ? 0 : insets.right,
}}
>
{
console.error("Video Error:", e.nativeEvent);
Alert.alert(
- "Error",
- "An error occurred while playing the video. Check logs in settings."
+ t("player.error"),
+ t("player.an_error_occured_while_playing_the_video")
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
diff --git a/app/(auth)/player/music-player.tsx b/app/(auth)/player/music-player.tsx
deleted file mode 100644
index eca16b4c..00000000
--- a/app/(auth)/player/music-player.tsx
+++ /dev/null
@@ -1,417 +0,0 @@
-import { Text } from "@/components/common/Text";
-import { Loader } from "@/components/Loader";
-import { Controls } from "@/components/video-player/controls/Controls";
-import { useOrientation } from "@/hooks/useOrientation";
-import { useOrientationSettings } from "@/hooks/useOrientationSettings";
-import { useWebSocket } from "@/hooks/useWebsockets";
-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 { 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 * as Haptics from "expo-haptics";
-import { Image } from "expo-image";
-import { useFocusEffect, useLocalSearchParams } from "expo-router";
-import { useAtomValue } from "jotai";
-import React, { useCallback, useMemo, useRef, useState } from "react";
-import { Pressable, useWindowDimensions, View } from "react-native";
-import { useSharedValue } from "react-native-reanimated";
-import Video, { OnProgressData, VideoRef } from "react-native-video";
-
-export default function page() {
- const api = useAtomValue(apiAtom);
- const user = useAtomValue(userAtom);
- const [settings] = useSettings();
- const videoRef = useRef(null);
- const windowDimensions = useWindowDimensions();
-
- const firstTime = useRef(true);
-
- 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 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) return;
- const res = await getUserLibraryApi(api).getItem({
- itemId,
- userId: user?.Id,
- });
-
- return res.data;
- },
- enabled: !!itemId && !!api,
- staleTime: 0,
- });
-
- const {
- data: stream,
- isLoading: isLoadingStreamUrl,
- isError: isErrorStreamUrl,
- } = useQuery({
- queryKey: ["stream-url"],
- queryFn: async () => {
- if (!api) return;
- const res = await getStreamUrl({
- api,
- item,
- startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
- userId: user?.Id,
- audioStreamIndex: audioIndex,
- maxStreamingBitrate: bitrateValue,
- mediaSourceId: mediaSourceId,
- subtitleStreamIndex: subtitleIndex,
- });
-
- if (!res) return null;
-
- const { mediaSource, sessionId, url } = res;
-
- if (!sessionId || !mediaSource || !url) return null;
-
- return {
- mediaSource,
- sessionId,
- url,
- };
- },
- });
-
- const poster = usePoster(item, api);
- const videoSource = useVideoSource(item, api, poster, stream?.url);
-
- const togglePlay = useCallback(
- async (ticks: number) => {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
- 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(ticks),
- 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(ticks),
- isPaused: false,
- playMethod: stream?.url.includes("m3u8")
- ? "Transcode"
- : "DirectStream",
- playSessionId: stream?.sessionId,
- });
- }
- },
- [
- isPlaying,
- api,
- item,
- videoRef,
- settings,
- audioIndex,
- subtitleIndex,
- mediaSourceId,
- stream,
- ]
- );
-
- const play = useCallback(() => {
- videoRef.current?.resume();
- reportPlaybackStart();
- }, [videoRef]);
-
- const pause = useCallback(() => {
- videoRef.current?.pause();
- }, [videoRef]);
-
- const stop = useCallback(() => {
- setIsPlaybackStopped(true);
- videoRef.current?.pause();
- reportPlaybackStopped();
- }, [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,
- });
- };
-
- 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 = data.currentTime * 10000000;
-
- progress.value = secondsToTicks(data.currentTime);
- cacheProgress.value = secondsToTicks(data.playableDuration);
- 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,
- audioIndex,
- subtitleIndex,
- mediaSourceId,
- stream,
- ]
- );
-
- useFocusEffect(
- useCallback(() => {
- play();
-
- return () => {
- stop();
- };
- }, [play, stop])
- );
-
- useOrientation();
- useOrientationSettings();
-
- useWebSocket({
- isPlaying: isPlaying,
- pauseVideo: pause,
- playVideo: play,
- stopPlayback: stop,
- });
-
- if (isLoadingItem || isLoadingStreamUrl)
- return (
-
-
-
- );
-
- if (isErrorItem || isErrorStreamUrl)
- return (
-
- Error
-
- );
-
- if (!item || !stream)
- return (
-
- Error
-
- );
-
- return (
-
-
-
-
-
- {
- setShowControls(!showControls);
- }}
- className="absolute z-0 h-full w-full opacity-0"
- >
- {videoSource && (
-
-
-
-
- );
-}
-
-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: {
- artist: item?.AlbumArtist ?? undefined,
- title: item?.Name || "Unknown",
- description: item?.Overview ?? undefined,
- imageUri: poster,
- subtitle: item?.Album ?? undefined,
- },
- };
- }, [item, api, poster]);
-
- return videoSource;
-}
diff --git a/app/(auth)/player/transcoding-player.tsx b/app/(auth)/player/transcoding-player.tsx
index 8a8b4a9f..38a2b2e5 100644
--- a/app/(auth)/player/transcoding-player.tsx
+++ b/app/(auth)/player/transcoding-player.tsx
@@ -20,7 +20,7 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
@@ -39,15 +39,18 @@ import Video, {
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(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);
@@ -58,7 +61,7 @@ const Player = () => {
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
}, []);
const progress = useSharedValue(0);
@@ -167,7 +170,7 @@ const Player = () => {
const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback(async () => {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
@@ -373,7 +376,7 @@ const Player = () => {
if (isErrorItem || isErrorStreamUrl)
return (
- Error
+ {t("player.error")}
);
@@ -387,7 +390,6 @@ const Player = () => {
position: "relative",
flexDirection: "column",
justifyContent: "center",
- opacity: showControls ? 0.5 : 1,
}}
>
{videoSource ? (
@@ -414,7 +416,6 @@ const Player = () => {
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
- pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
@@ -441,7 +442,7 @@ const Player = () => {
/>
>
) : (
- No video source...
+ {t("player.no_video_source")}
)}
@@ -532,7 +533,6 @@ export function useVideoSource(
startPosition,
headers: getAuthHeaders(api),
metadata: {
- artist: item?.AlbumArtist ?? undefined,
title: item?.Name || "Unknown",
description: item?.Overview ?? undefined,
imageUri: poster,
diff --git a/app/(auth)/trailer/page.tsx b/app/(auth)/trailer/page.tsx
deleted file mode 100644
index 9f331795..00000000
--- a/app/(auth)/trailer/page.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { useGlobalSearchParams } from "expo-router";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { Alert, Dimensions, View } from "react-native";
-import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
-
-export default function page() {
- const searchParams = useGlobalSearchParams();
-
- const { url } = searchParams as { url: string };
-
- const videoId = useMemo(() => {
- return url.split("v=")[1];
- }, [url]);
-
- const [playing, setPlaying] = useState(false);
-
- const onStateChange = useCallback((state: PLAYER_STATES) => {
- if (state === "ended") {
- setPlaying(false);
- Alert.alert("video has finished playing!");
- }
- }, []);
-
- const togglePlaying = useCallback(() => {
- setPlaying((prev) => !prev);
- }, []);
-
- useEffect(() => {
- togglePlaying();
- }, []);
-
- const screenWidth = Dimensions.get("screen").width;
-
- return (
-
-
-
- );
-}
diff --git a/app/+not-found.tsx b/app/+not-found.tsx
index 41968287..5a8c1964 100644
--- a/app/+not-found.tsx
+++ b/app/+not-found.tsx
@@ -1,13 +1,10 @@
-import { Link, Stack, usePathname } from "expo-router";
+import { Link, Stack } from "expo-router";
import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView";
-import { useEffect } from "react";
export default function NotFoundScreen() {
- const pathname = usePathname();
-
return (
<>
diff --git a/app/_layout.tsx b/app/_layout.tsx
index bf779be5..2092d722 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -40,6 +40,9 @@ import { useEffect, useRef } from "react";
import { Appearance, AppState, TouchableOpacity } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
+import { I18nextProvider, useTranslation } from "react-i18next";
+import i18n from "@/i18n";
+import { getLocales } from "expo-localization";
import "react-native-reanimated";
import { Toaster } from "sonner-native";
@@ -228,7 +231,9 @@ export default function RootLayout() {
return (
-
+
+
+
);
}
@@ -252,6 +257,8 @@ function Layout() {
useKeepAwake();
useNotificationObserver();
+ const { i18n } = useTranslation();
+
useEffect(() => {
checkAndRequestPermissions();
}, []);
@@ -265,6 +272,12 @@ function Layout() {
);
}, [settings]);
+ useEffect(() => {
+ i18n.changeLanguage(
+ settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
+ );
+ }, [settings?.preferedLanguage, i18n]);
+
const appState = useRef(AppState.currentState);
useEffect(() => {
@@ -336,14 +349,6 @@ function Layout() {
header: () => null,
}}
/>
-
{
+ const Login: React.FC = () => {
const { setServer, login, removeServer, initiateQuickConnect } =
useJellyfin();
const [api] = useAtom(apiAtom);
@@ -39,7 +39,6 @@ const Login: React.FC = () => {
const [serverURL, setServerURL] = useState(_apiUrl);
const [serverName, setServerName] = useState("");
- const [error, setError] = useState("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
@@ -77,8 +76,10 @@ const Login: React.FC = () => {
onPress={() => {
removeServer();
}}
+ className="flex flex-row items-center"
>
-
+
+ {t("login.change_server")}
) : null,
});
@@ -95,9 +96,9 @@ const Login: React.FC = () => {
}
} catch (error) {
if (error instanceof Error) {
- setError(error.message);
+ Alert.alert(t("login.connection_failed"), error.message);
} else {
- setError("An unexpected error occurred");
+ Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
}
} finally {
setLoading(false);
@@ -136,6 +137,8 @@ const Login: React.FC = () => {
return url;
}
+ return undefined;
+ } catch {
return undefined;
} finally {
setLoadingServerCheck(false);
@@ -159,14 +162,13 @@ const Login: React.FC = () => {
*
*/
const handleConnect = useCallback(async (url: string) => {
- url = url.trim();
-
+ url = url.trim().replace(/\/$/, "");
const result = await checkUrl(url);
if (result === undefined) {
Alert.alert(
- "Connection failed",
- "Could not connect to the server. Please check the URL and your network connection."
+ t("login.connection_failed"),
+ t("login.could_not_connect_to_server")
);
return;
}
@@ -178,144 +180,149 @@ const Login: React.FC = () => {
try {
const code = await initiateQuickConnect();
if (code) {
- Alert.alert("Quick Connect", `Enter code ${code} to login`, [
+ Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
{
- text: "Got It",
+ text: t("login.got_it"),
},
]);
}
} catch (error) {
- Alert.alert("Error", "Failed to initiate Quick Connect");
+ Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect"));
}
};
- if (api?.basePath) {
- return (
-
-
-
-
-
+ return (
+
+
+ {api?.basePath ? (
+ <>
+
+
+
- Log in
<>
{serverName ? (
<>
- {" to "}
+ {t("login.login_to_title") + " "}
{serverName}
>
- ) : null}
+ ) : t("login.login_title")}
>
- {api.basePath}
-
- setCredentials({ ...credentials, username: text })
- }
- value={credentials.username}
- autoFocus
- secureTextEntry={false}
- keyboardType="default"
- returnKeyType="done"
- autoCapitalize="none"
- textContentType="username"
- clearButtonMode="while-editing"
- maxLength={500}
- />
+
+ {api.basePath}
+
+
+ setCredentials({ ...credentials, username: text })
+ }
+ value={credentials.username}
+ autoFocus
+ secureTextEntry={false}
+ keyboardType="default"
+ returnKeyType="done"
+ autoCapitalize="none"
+ textContentType="username"
+ clearButtonMode="while-editing"
+ maxLength={500}
+ />
-
- setCredentials({ ...credentials, password: text })
- }
- value={credentials.password}
- secureTextEntry
- keyboardType="default"
- returnKeyType="done"
- autoCapitalize="none"
- textContentType="password"
- clearButtonMode="while-editing"
- maxLength={500}
- />
+
+ setCredentials({ ...credentials, password: text })
+ }
+ value={credentials.password}
+ secureTextEntry
+ keyboardType="default"
+ returnKeyType="done"
+ autoCapitalize="none"
+ textContentType="password"
+ clearButtonMode="while-editing"
+ maxLength={500}
+ />
+
+
+
+
+
+
+
- {error}
+
-
-
-
-
+ >
+ ) : (
+ <>
+
+
+
+ Streamyfin
+
+ {t("server.enter_url_to_jellyfin_server")}
+
+
+
+ {
+ setServerURL(server.address);
+ if (server.serverName) {
+ setServerName(server.serverName);
+ }
+ handleConnect(server.address);
+ }}
+ />
+ {
+ handleConnect(s.address);
+ }}
+ />
+
-
-
-
- );
- }
-
- return (
-
-
-
-
-
- Streamyfin
-
- Enter the URL to your Jellyfin server
-
-
-
- Make sure to include http or https
-
- {
- handleConnect(s.address);
- }}
- />
-
-
-
-
-
+ >
+ )}
);
diff --git a/assets/icons/jellyseerr-logo.svg b/assets/icons/jellyseerr-logo.svg
new file mode 100644
index 00000000..cda2394d
--- /dev/null
+++ b/assets/icons/jellyseerr-logo.svg
@@ -0,0 +1,118 @@
+
+
\ No newline at end of file
diff --git a/assets/images/jellyseerr.PNG b/assets/images/jellyseerr.PNG
new file mode 100644
index 00000000..c72a8da1
Binary files /dev/null and b/assets/images/jellyseerr.PNG differ
diff --git a/augmentations/api.ts b/augmentations/api.ts
new file mode 100644
index 00000000..da5c02a9
--- /dev/null
+++ b/augmentations/api.ts
@@ -0,0 +1,46 @@
+import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
+import { AxiosRequestConfig, AxiosResponse } from "axios";
+import { StreamyfinPluginConfig } from "@/utils/atoms/settings";
+
+declare module "@jellyfin/sdk" {
+ interface Api {
+ get(
+ url: string,
+ config?: AxiosRequestConfig
+ ): Promise>;
+ post(
+ url: string,
+ data: D,
+ config?: AxiosRequestConfig
+ ): Promise>;
+ getStreamyfinPluginConfig(): Promise>;
+ }
+}
+
+Api.prototype.get = function (
+ url: string,
+ config: AxiosRequestConfig = {}
+): Promise> {
+ return this.axiosInstance.get(`${this.basePath}${url}`, {
+ ...(config ?? {}),
+ headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
+ });
+};
+
+Api.prototype.post = function (
+ url: string,
+ data: D,
+ config: AxiosRequestConfig
+): Promise> {
+ return this.axiosInstance.post(`${this.basePath}${url}`, {
+ ...(config || {}),
+ data,
+ headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
+ });
+};
+
+Api.prototype.getStreamyfinPluginConfig = function (): Promise<
+ AxiosResponse
+> {
+ return this.get("/Streamyfin/config");
+};
diff --git a/augmentations/index.ts b/augmentations/index.ts
index 22ca2cb0..abec02c9 100644
--- a/augmentations/index.ts
+++ b/augmentations/index.ts
@@ -1,3 +1,4 @@
+export * from "./api";
export * from "./mmkv";
export * from "./number";
export * from "./string";
diff --git a/augmentations/mmkv.ts b/augmentations/mmkv.ts
index 80fbeede..5667502f 100644
--- a/augmentations/mmkv.ts
+++ b/augmentations/mmkv.ts
@@ -13,5 +13,10 @@ MMKV.prototype.get = function (key: string): T | undefined {
}
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
- this.set(key, JSON.stringify(value));
+ if (value === undefined) {
+ this.delete(key)
+ }
+ else {
+ this.set(key, JSON.stringify(value));
+ }
}
\ No newline at end of file
diff --git a/augmentations/number.ts b/augmentations/number.ts
index c0f53075..15b70507 100644
--- a/augmentations/number.ts
+++ b/augmentations/number.ts
@@ -1,25 +1,23 @@
declare global {
interface Number {
- bytesToReadable(): string;
+ bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
}
}
-Number.prototype.bytesToReadable = function () {
+Number.prototype.bytesToReadable = function (decimals: number = 2) {
const bytes = this.valueOf();
- const gb = bytes / 1e9;
+ if (bytes === 0) return '0 Bytes';
- if (gb >= 1) return `${gb.toFixed(0)} GB`;
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
- const mb = bytes / 1024.0 / 1024.0;
- if (mb >= 1) return `${mb.toFixed(0)} MB`;
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
- const kb = bytes / 1024.0;
- if (kb >= 1) return `${kb.toFixed(0)} KB`;
-
- return `${bytes.toFixed(2)} B`;
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
Number.prototype.secondsToMilliseconds = function () {
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 00000000..d3b67a76
Binary files /dev/null and b/bun.lockb differ
diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx
index 75fd659c..b4ab7b9a 100644
--- a/components/AudioTrackSelector.tsx
+++ b/components/AudioTrackSelector.tsx
@@ -3,6 +3,7 @@ import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
+import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps {
source?: MediaSourceInfo;
@@ -26,6 +27,8 @@ export const AudioTrackSelector: React.FC = ({
[audioStreams, selected]
);
+ const { t } = useTranslation();
+
return (
= ({
- Audio
+ {t("item_card.audio")}
{selectedAudioSteam?.DisplayTitle}
diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx
index 0f1bd28b..be00cc9e 100644
--- a/components/BitrateSelector.tsx
+++ b/components/BitrateSelector.tsx
@@ -2,6 +2,7 @@ import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
export type Bitrate = {
key: string;
@@ -27,6 +28,10 @@ export const BITRATES: Bitrate[] = [
key: "2 Mb/s",
value: 2000000,
},
+ {
+ key: "1 Mb/s",
+ value: 1000000,
+ },
{
key: "500 Kb/s",
value: 500000,
@@ -59,6 +64,8 @@ export const BitrateSelector: React.FC = ({
);
}, []);
+ const { t } = useTranslation();
+
return (
= ({
- Quality
+ {t("item_card.quality")}
{BITRATES.find((b) => b.value === selected?.value)?.key}
diff --git a/components/Button.tsx b/components/Button.tsx
index 1a73ad01..4f7e25c4 100644
--- a/components/Button.tsx
+++ b/components/Button.tsx
@@ -1,4 +1,4 @@
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader";
@@ -37,12 +37,14 @@ export const Button: React.FC> = ({
case "red":
return "bg-red-600";
case "black":
- return "bg-neutral-900 border border-neutral-800";
+ return "bg-neutral-900";
case "transparent":
return "bg-transparent";
}
}, [color]);
+ const lightHapticFeedback = useHaptic("light");
+
return (
> = ({
onPress={() => {
if (!loading && !disabled && onPress) {
onPress();
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
}
}}
disabled={disabled || loading}
{...props}
>
{loading ? (
-
+
+
+
) : (
= ({
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
+
+ if (item.ImageTags?.["Thumb"])
+ return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
+ else
+ return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}, [item]);
const progress = useMemo(() => {
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index 4618bb4f..dcb14128 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
-import { useSettings } from "@/utils/atoms/settings";
+import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
@@ -32,6 +32,7 @@ import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
+import { t } from "i18next";
interface DownloadProps extends ViewProps {
items: BaseItemDto[];
@@ -55,6 +56,7 @@ export const DownloadItems: React.FC = ({
const [user] = useAtom(userAtom);
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
+
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
const { startRemuxing } = useRemuxHlsToMp4();
@@ -74,7 +76,7 @@ export const DownloadItems: React.FC = ({
[user]
);
const usingOptimizedServer = useMemo(
- () => settings?.downloadMethod === "optimized",
+ () => settings?.downloadMethod === DownloadMethod.Optimized,
[settings]
);
@@ -160,7 +162,7 @@ export const DownloadItems: React.FC = ({
);
}
} else {
- toast.error("You are not allowed to download files.");
+ toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files"));
}
}, [
queue,
@@ -212,8 +214,8 @@ export const DownloadItems: React.FC = ({
if (!res) {
Alert.alert(
- "Something went wrong",
- "Could not get stream url from Jellyfin"
+ t("home.downloads.something_went_wrong"),
+ t("home.downloads.could_not_get_stream_url_from_jellyfin")
);
continue;
}
@@ -330,7 +332,7 @@ export const DownloadItems: React.FC = ({
{title}
- {subtitle || `Download ${itemsNotDownloaded.length} items`}
+ {subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})}
@@ -368,13 +370,13 @@ export const DownloadItems: React.FC = ({
onPress={acceptDownloadOptions}
color="purple"
>
- Download
+ {t("item_card.download.download_button")}
{usingOptimizedServer
- ? "Using optimized server"
- : "Using default method"}
+ ? t("item_card.download.using_optimized_server")
+ : t("item_card.download.using_default_method")}
@@ -391,7 +393,9 @@ export const DownloadSingleItem: React.FC<{
return (
(
diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx
index cc5db670..35de54a6 100644
--- a/components/GenreTags.tsx
+++ b/components/GenreTags.tsx
@@ -1,6 +1,6 @@
// GenreTags.tsx
import React from "react";
-import {View, ViewProps} from "react-native";
+import {StyleProp, TextStyle, View, ViewProps} from "react-native";
import { Text } from "./common/Text";
interface TagProps {
@@ -8,14 +8,15 @@ interface TagProps {
textClass?: ViewProps["className"]
}
-export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
+export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp} & ViewProps> = ({
text,
textClass,
+ textStyle,
...props
}) => {
return (
- {text}
+ {text}
);
};
diff --git a/components/ItemTechnicalDetails.tsx b/components/ItemTechnicalDetails.tsx
index 0c472192..6b5852a4 100644
--- a/components/ItemTechnicalDetails.tsx
+++ b/components/ItemTechnicalDetails.tsx
@@ -15,6 +15,7 @@ import {
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { Button } from "./Button";
+import { useTranslation } from "react-i18next";
interface Props {
source?: MediaSourceInfo;
@@ -22,15 +23,16 @@ interface Props {
export const ItemTechnicalDetails: React.FC = ({ source, ...props }) => {
const bottomSheetModalRef = useRef(null);
+ const { t } = useTranslation();
return (
- Video
+ {t("item_card.video")}
bottomSheetModalRef.current?.present()}>
- More details
+ {t("item_card.more_details")}
= ({ source, ...props }) => {
- Video
+ {t("item_card.video")}
- Audio
+ {t("item_card.audio")}
= ({ source, ...props }) => {
- Subtitles
+ {t("item_card.subtitles")}
void;
+}
+
+const JellyfinServerDiscovery: React.FC = ({ onServerSelect }) => {
+ const { servers, isSearching, startDiscovery } = useJellyfinDiscovery();
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {servers.length ? (
+
+ {servers.map((server) => (
+
+ onServerSelect?.({
+ address: server.address,
+ serverName: server.serverName,
+ })
+ }
+ title={server.address}
+ showArrow
+ />
+ ))}
+
+ ) : null}
+
+ );
+};
+
+export default JellyfinServerDiscovery;
diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx
index 34f02fd9..1a187e62 100644
--- a/components/MediaSourceSelector.tsx
+++ b/components/MediaSourceSelector.tsx
@@ -1,13 +1,12 @@
-import { tc } from "@/utils/textTools";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
-import { useEffect, useMemo } from "react";
+import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
-import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
+import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps {
item: BaseItemDto;
@@ -29,6 +28,29 @@ export const MediaSourceSelector: React.FC = ({
[item, selected]
);
+ const { t } = useTranslation();
+
+ const commonPrefix = useMemo(() => {
+ const mediaSources = item.MediaSources || [];
+ if (!mediaSources.length) return "";
+
+ let commonPrefix = "";
+ for (let i = 0; i < mediaSources[0].Name!.length; i++) {
+ const char = mediaSources[0].Name![i];
+ if (mediaSources.every((source) => source.Name![i] === char)) {
+ commonPrefix += char;
+ } else {
+ commonPrefix = commonPrefix.slice(0, -1);
+ break;
+ }
+ }
+ return commonPrefix;
+ }, [item.MediaSources]);
+
+ const name = (name?: string | null) => {
+ return name?.replace(commonPrefix, "").toLowerCase();
+ };
+
return (
= ({
- Video
+ {t("item_card.video")}
{selectedName}
@@ -63,9 +85,7 @@ export const MediaSourceSelector: React.FC = ({
}}
>
- {`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
- source.Size
- )}`}
+ {`${name(source.Name)}`}
))}
@@ -74,9 +94,3 @@ export const MediaSourceSelector: React.FC = ({
);
};
-
-const name = (name?: string | null) => {
- if (name && name.length > 40)
- return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
- return name;
-};
diff --git a/components/MoreMoviesWithActor.tsx b/components/MoreMoviesWithActor.tsx
index 9a2a044f..9d4bc500 100644
--- a/components/MoreMoviesWithActor.tsx
+++ b/components/MoreMoviesWithActor.tsx
@@ -11,6 +11,7 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
+import { useTranslation } from "react-i18next";
interface Props extends ViewProps {
actorId: string;
@@ -24,6 +25,7 @@ export const MoreMoviesWithActor: React.FC = ({
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
+ const { t } = useTranslation();
const { data: actor } = useQuery({
queryKey: ["actor", actorId],
@@ -76,7 +78,7 @@ export const MoreMoviesWithActor: React.FC = ({
return (
- More with {actor?.Name}
+ {t("item_card.more_with", {name: actor?.Name})}
= ({
...props
}) => {
const [limit, setLimit] = useState(characterLimit);
+ const { t } = useTranslation();
if (!text) return null;
return (
- Overview
+ {t("item_card.overview")}
setLimit((prev) =>
@@ -31,7 +33,7 @@ export const OverviewText: React.FC = ({
{tc(text, limit)}
{text.length > characterLimit && (
- {limit === characterLimit ? "Show more" : "Show less"}
+ {limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")}
)}
diff --git a/components/ParallaxPage.tsx b/components/ParallaxPage.tsx
index daebca6b..5d7b28e0 100644
--- a/components/ParallaxPage.tsx
+++ b/components/ParallaxPage.tsx
@@ -1,6 +1,6 @@
import { LinearGradient } from "expo-linear-gradient";
import { type PropsWithChildren, type ReactElement } from "react";
-import { View, ViewProps } from "react-native";
+import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native";
import Animated, {
interpolate,
useAnimatedRef,
@@ -13,6 +13,7 @@ interface Props extends ViewProps {
logo?: ReactElement;
episodePoster?: ReactElement;
headerHeight?: number;
+ onEndReached?: (() => void) | null | undefined;
}
export const ParallaxScrollView: React.FC> = ({
@@ -21,6 +22,7 @@ export const ParallaxScrollView: React.FC> = ({
episodePoster,
headerHeight = 400,
logo,
+ onEndReached,
...props
}: Props) => {
const scrollRef = useAnimatedRef();
@@ -47,6 +49,11 @@ export const ParallaxScrollView: React.FC> = ({
};
});
+
+ function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) {
+ return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
+ }
+
return (
> = ({
}}
ref={scrollRef}
scrollEventThrottle={16}
+ onScroll={e => {
+ if (isCloseToBottom(e.nativeEvent))
+ onEndReached?.()
+ }}
>
{logo && (
{
item: BaseItemDto;
@@ -50,6 +51,7 @@ export const PlayButton: React.FC = ({
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus();
+ const { t } = useTranslation();
const [colorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
@@ -64,6 +66,7 @@ export const PlayButton: React.FC = ({
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings();
+ const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => {
@@ -79,7 +82,7 @@ export const PlayButton: React.FC = ({
const onPress = useCallback(async () => {
if (!item) return;
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
const queryParams = new URLSearchParams({
itemId: item.Id!,
@@ -131,8 +134,8 @@ export const PlayButton: React.FC = ({
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
- "Client error",
- "Could not create stream for Chromecast"
+ t("player.client_error"),
+ t("player.could_not_create_stream_for_chromecast")
);
return;
}
diff --git a/components/PreviousServersList.tsx b/components/PreviousServersList.tsx
index 3771961e..437c756d 100644
--- a/components/PreviousServersList.tsx
+++ b/components/PreviousServersList.tsx
@@ -3,6 +3,7 @@ import { View } from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem";
+import { useTranslation } from "react-i18next";
interface Server {
address: string;
@@ -22,11 +23,13 @@ export const PreviousServersList: React.FC = ({
return JSON.parse(_previousServers || "[]") as Server[];
}, [_previousServers]);
+ const { t } = useTranslation();
+
if (!previousServers.length) return null;
return (
-
+
{previousServers.map((s) => (
= ({
onPress={() => {
setPreviousServers("[]");
}}
- title={"Clear"}
+ title={t("server.clear_button")}
textColor="red"
/>
diff --git a/components/RoundButton.tsx b/components/RoundButton.tsx
index 049c5ed0..5d2faf73 100644
--- a/components/RoundButton.tsx
+++ b/components/RoundButton.tsx
@@ -6,7 +6,7 @@ import {
TouchableOpacity,
TouchableOpacityProps,
} from "react-native";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
interface Props extends TouchableOpacityProps {
onPress?: () => void;
@@ -29,10 +29,11 @@ export const RoundButton: React.FC> = ({
}) => {
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
+ const lightHapticFeedback = useHaptic("light");
const handlePress = () => {
if (hapticFeedback) {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
}
onPress?.();
};
diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx
index 46815b6d..45914d9f 100644
--- a/components/SimilarItems.tsx
+++ b/components/SimilarItems.tsx
@@ -12,6 +12,7 @@ import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader";
import { HorizontalScroll } from "./common/HorrizontalScroll";
import { TouchableItemRouter } from "./common/TouchableItemRouter";
+import { useTranslation } from "react-i18next";
interface SimilarItemsProps extends ViewProps {
itemId?: string | null;
@@ -23,6 +24,7 @@ export const SimilarItems: React.FC = ({
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
+ const { t } = useTranslation();
const { data: similarItems, isLoading } = useQuery({
queryKey: ["similarItems", itemId],
@@ -47,12 +49,12 @@ export const SimilarItems: React.FC = ({
return (
- Similar items
+ {t("item_card.similar_items")}
(
{
source?: MediaSourceInfo;
@@ -37,6 +38,8 @@ export const SubtitleTrackSelector: React.FC = ({
if (subtitleStreams.length === 0) return null;
+ const { t } = useTranslation();
+
return (
= ({
- Subtitle
+ {t("item_card.subtitles")}
{selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
- : "None"}
+ : t("item_card.none")}
diff --git a/components/common/Dropdown.tsx b/components/common/Dropdown.tsx
new file mode 100644
index 00000000..fec36d2f
--- /dev/null
+++ b/components/common/Dropdown.tsx
@@ -0,0 +1,108 @@
+import * as DropdownMenu from "zeego/dropdown-menu";
+import {TouchableOpacity, View, ViewProps} from "react-native";
+import {Text} from "@/components/common/Text";
+import React, {PropsWithChildren, ReactNode, useEffect, useState} from "react";
+import DisabledSetting from "@/components/settings/DisabledSetting";
+
+interface Props {
+ data: T[]
+ disabled?: boolean
+ placeholderText?: string,
+ keyExtractor: (item: T) => string
+ titleExtractor: (item: T) => string | undefined
+ title: string | ReactNode,
+ label: string,
+ onSelected: (...item: T[]) => void
+ multi?: boolean
+}
+
+const Dropdown = ({
+ data,
+ disabled,
+ placeholderText,
+ keyExtractor,
+ titleExtractor,
+ title,
+ label,
+ onSelected,
+ multi = false,
+ ...props
+}: PropsWithChildren & ViewProps>) => {
+ const [selected, setSelected] = useState();
+
+ useEffect(() => {
+ if (selected !== undefined) {
+ onSelected(...selected)
+ }
+ }, [selected]);
+
+ return (
+
+
+
+ {typeof title === 'string' ? (
+
+
+ {title}
+
+
+
+ {selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText}
+
+
+
+ ) : (
+ <>
+ {title}
+ >
+ )}
+
+
+ {label}
+ {data.map((item, idx) => (
+ multi ? (
+ keyExtractor(s) == keyExtractor(item)) ? 'on' : 'off'}
+ key={keyExtractor(item)}
+ onValueChange={(next, previous) =>
+ setSelected((p) => {
+ const prev = p || []
+ if (next == 'on') {
+ return [...prev, item]
+ }
+ return [...prev.filter(p => keyExtractor(p) !== keyExtractor(item))]
+ })
+ }
+ >
+ {titleExtractor(item)}
+
+ )
+ : (
+ setSelected([item])}
+ >
+ {titleExtractor(item)}
+
+ )
+ ))}
+
+
+
+ )
+};
+
+export default Dropdown;
\ No newline at end of file
diff --git a/components/common/InfiniteHorrizontalScroll.tsx b/components/common/InfiniteHorrizontalScroll.tsx
index e8281d0e..f3c504f1 100644
--- a/components/common/InfiniteHorrizontalScroll.tsx
+++ b/components/common/InfiniteHorrizontalScroll.tsx
@@ -15,6 +15,7 @@ import Animated, {
} from "react-native-reanimated";
import { Loader } from "../Loader";
import { Text } from "./Text";
+import { t } from "i18next";
interface HorizontalScrollProps
extends Omit, "renderItem" | "data" | "style"> {
@@ -136,7 +137,7 @@ export function InfiniteHorizontalScroll({
showsHorizontalScrollIndicator={false}
ListEmptyComponent={
- No data available
+ {t("item_card.no_data_available")}
}
{...props}
diff --git a/components/common/Input.tsx b/components/common/Input.tsx
index ba4ab45b..d82a3225 100644
--- a/components/common/Input.tsx
+++ b/components/common/Input.tsx
@@ -7,7 +7,7 @@ export function Input(props: TextInputProps) {
return (
> = ({
}) => {
const router = useRouter();
const segments = useSegments();
+ const { showActionSheetWithOptions } = useActionSheet();
+ const markAsPlayedStatus = useMarkAsPlayed(item);
const from = segments[2];
- const markAsPlayedStatus = useMarkAsPlayed(item);
+ const showActionSheet = useCallback(() => {
+ if (!(item.Type === "Movie" || item.Type === "Episode")) return;
+
+ const options = ["Mark as Played", "Mark as Not Played", "Cancel"];
+ const cancelButtonIndex = 2;
+
+ showActionSheetWithOptions(
+ {
+ options,
+ cancelButtonIndex,
+ },
+ async (selectedIndex) => {
+ if (selectedIndex === 0) {
+ await markAsPlayedStatus(true);
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ } else if (selectedIndex === 1) {
+ await markAsPlayedStatus(false);
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ }
+ }
+ );
+ }, [showActionSheetWithOptions, markAsPlayedStatus]);
if (
from === "(home)" ||
@@ -78,9 +92,10 @@ export const TouchableItemRouter: React.FC> = ({
)
return (
{
const url = itemRouter(item, from);
- // @ts-ignore
+ // @ts-expect-error
router.push(url);
}}
{...props}
diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx
index 556ae8c7..47c79f5d 100644
--- a/components/downloads/ActiveDownloads.tsx
+++ b/components/downloads/ActiveDownloads.tsx
@@ -1,7 +1,6 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
-import { apiAtom } from "@/providers/JellyfinProvider";
-import { useSettings } from "@/utils/atoms/settings";
+import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
@@ -9,7 +8,6 @@ import { checkForExistingDownloads } from "@kesha-antonov/react-native-backgroun
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { FFmpegKit } from "ffmpeg-kit-react-native";
-import { useAtom } from "jotai";
import {
ActivityIndicator,
TouchableOpacity,
@@ -22,6 +20,7 @@ import { Button } from "../Button";
import { Image } from "expo-image";
import { useMemo } from "react";
import { storage } from "@/utils/mmkv";
+import { t } from "i18next";
interface Props extends ViewProps {}
@@ -30,14 +29,14 @@ export const ActiveDownloads: React.FC = ({ ...props }) => {
if (processes?.length === 0)
return (
- Active download
- No active downloads
+ {t("home.downloads.active_download")}
+ {t("home.downloads.no_active_downloads")}
);
return (
- Active downloads
+ {t("home.downloads.active_downloads")}
{processes?.map((p) => (
@@ -62,7 +61,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
mutationFn: async (id: string) => {
if (!process) throw new Error("No active download");
- if (settings?.downloadMethod === "optimized") {
+ if (settings?.downloadMethod === DownloadMethod.Optimized) {
try {
const tasks = await checkForExistingDownloads();
for (const task of tasks) {
@@ -82,11 +81,11 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}
},
onSuccess: () => {
- toast.success("Download canceled");
+ toast.success(t("home.downloads.toasts.download_cancelled"));
},
onError: (e) => {
console.error(e);
- toast.error("Could not cancel download");
+ toast.error(t("home.downloads.toasts.could_not_cancel_download"));
},
});
@@ -153,7 +152,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
{process.speed?.toFixed(2)}x
)}
{eta(process) && (
- ETA {eta(process)}
+ {t("home.downloads.eta", {eta: eta(process)})}
)}
diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx
index e8387da5..53b3ecec 100644
--- a/components/downloads/EpisodeCard.tsx
+++ b/components/downloads/EpisodeCard.tsx
@@ -1,5 +1,5 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import {
@@ -26,6 +26,7 @@ export const EpisodeCard: React.FC = ({ item, ...props }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
+ const successHapticFeedback = useHaptic("success");
const base64Image = useMemo(() => {
return storage.getString(item.Id!);
@@ -41,7 +42,7 @@ export const EpisodeCard: React.FC = ({ item, ...props }) => {
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ successHapticFeedback();
}
}, [deleteFile, item.Id]);
diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx
index 3073bd0a..bb61f3c8 100644
--- a/components/downloads/MovieCard.tsx
+++ b/components/downloads/MovieCard.tsx
@@ -3,7 +3,7 @@ import {
useActionSheet,
} from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
@@ -28,6 +28,7 @@ export const MovieCard: React.FC = ({ item }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
+ const successHapticFeedback = useHaptic("success");
const handleOpenFile = useCallback(() => {
openFile(item);
@@ -43,7 +44,7 @@ export const MovieCard: React.FC = ({ item }) => {
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ successHapticFeedback();
}
}, [deleteFile, item.Id]);
diff --git a/components/filters/FilterButton.tsx b/components/filters/FilterButton.tsx
index 6d976b26..de8caa2e 100644
--- a/components/filters/FilterButton.tsx
+++ b/components/filters/FilterButton.tsx
@@ -1,7 +1,7 @@
import { Text } from "@/components/common/Text";
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { FilterSheet } from "./FilterSheet";
diff --git a/components/filters/FilterSheet.tsx b/components/filters/FilterSheet.tsx
index fe6d9f6a..cc5d4300 100644
--- a/components/filters/FilterSheet.tsx
+++ b/components/filters/FilterSheet.tsx
@@ -19,6 +19,7 @@ import { StyleSheet, TouchableOpacity, View, ViewProps } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Button } from "../Button";
import { Input } from "../common/Input";
+import { useTranslation } from "react-i18next";
interface Props extends ViewProps {
open: boolean;
@@ -76,6 +77,7 @@ export const FilterSheet = ({
}: Props) => {
const bottomSheetModalRef = useRef(null);
const snapPoints = useMemo(() => ["80%"], []);
+ const { t } = useTranslation();
const [data, setData] = useState([]);
const [offset, setOffset] = useState(0);
@@ -153,10 +155,10 @@ export const FilterSheet = ({
>
{title}
- {_data?.length} items
+ {t("search.items", {count: _data?.length})}
{showSearch && (
{
diff --git a/components/home/Favorites.tsx b/components/home/Favorites.tsx
index 6cf05109..c4ab373e 100644
--- a/components/home/Favorites.tsx
+++ b/components/home/Favorites.tsx
@@ -5,6 +5,7 @@ import { View } from "react-native";
import { ScrollingCollectionList } from "./ScrollingCollectionList";
import { useCallback } from "react";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
+import { t } from "i18next";
export const Favorites = () => {
const [api] = useAtom(apiAtom);
@@ -54,64 +55,44 @@ export const Favorites = () => {
() => fetchFavoritesByType("Playlist"),
[fetchFavoritesByType]
);
- const fetchFavoriteMusicAlbum = useCallback(
- () => fetchFavoritesByType("MusicAlbum"),
- [fetchFavoritesByType]
- );
- const fetchFavoriteAudio = useCallback(
- () => fetchFavoritesByType("Audio"),
- [fetchFavoritesByType]
- );
return (
-
-
diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx
index a22c586f..5b228901 100644
--- a/components/home/LargeMovieCarousel.tsx
+++ b/components/home/LargeMovieCarousel.tsx
@@ -1,3 +1,4 @@
+import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
@@ -6,9 +7,11 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
+import { useRouter, useSegments } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react";
-import { Dimensions, TouchableOpacity, View, ViewProps } from "react-native";
+import { Dimensions, View, ViewProps } from "react-native";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
runOnJS,
useSharedValue,
@@ -18,11 +21,7 @@ import Carousel, {
ICarouselInstance,
Pagination,
} from "react-native-reanimated-carousel";
-import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
-import { Loader } from "../Loader";
-import { Gesture, GestureDetector } from "react-native-gesture-handler";
-import { useRouter, useSegments } from "expo-router";
-import * as Haptics from "expo-haptics";
+import { itemRouter } from "../common/TouchableItemRouter";
interface Props extends ViewProps {}
@@ -128,6 +127,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const [api] = useAtom(apiAtom);
const router = useRouter();
const screenWidth = Dimensions.get("screen").width;
+ const lightHapticFeedback = useHaptic("light");
const uri = useMemo(() => {
if (!api) return null;
@@ -153,7 +153,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const handleRoute = useCallback(() => {
if (!from) return;
const url = itemRouter(item, from);
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
// @ts-ignore
if (url) router.push(url);
}, [item, from]);
@@ -161,7 +161,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const tap = Gesture.Tap()
.maxDuration(2000)
.onBegin(() => {
- opacity.value = withTiming(0.5, { duration: 100 });
+ opacity.value = withTiming(0.8, { duration: 100 });
})
.onEnd(() => {
runOnJS(handleRoute)();
diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx
index 17b4ec77..06f5a057 100644
--- a/components/home/ScrollingCollectionList.tsx
+++ b/components/home/ScrollingCollectionList.tsx
@@ -11,6 +11,7 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import SeriesPoster from "../posters/SeriesPoster";
+import { useTranslation } from "react-i18next";
interface Props extends ViewProps {
title?: string | null;
@@ -39,9 +40,10 @@ export const ScrollingCollectionList: React.FC = ({
refetchOnReconnect: true,
});
- if (disabled || !title) return null;
+ const { t } = useTranslation();
if (hideIfEmpty === true && data?.length === 0) return null;
+ if (disabled || !title) return null;
return (
@@ -50,7 +52,7 @@ export const ScrollingCollectionList: React.FC = ({
{isLoading === false && data?.length === 0 && (
- No items
+ {t("home.no_items")}
)}
{isLoading ? (
@@ -104,7 +106,12 @@ export const ScrollingCollectionList: React.FC = ({
{item.Type === "Movie" && orientation === "vertical" && (
)}
- {item.Type === "Series" && }
+ {item.Type === "Series" && orientation === "vertical" && (
+
+ )}
+ {item.Type === "Series" && orientation === "horizontal" && (
+
+ )}
{item.Type === "Program" && (
)}
diff --git a/components/inputs/Stepper.tsx b/components/inputs/Stepper.tsx
index eb5032cf..86a4ffa9 100644
--- a/components/inputs/Stepper.tsx
+++ b/components/inputs/Stepper.tsx
@@ -1,8 +1,10 @@
import {TouchableOpacity, View} from "react-native";
import {Text} from "@/components/common/Text";
+import DisabledSetting from "@/components/settings/DisabledSetting";
interface StepperProps {
value: number,
+ disabled?: boolean,
step: number,
min: number,
max: number,
@@ -12,6 +14,7 @@ interface StepperProps {
export const Stepper: React.FC = ({
value,
+ disabled,
step,
min,
max,
@@ -19,7 +22,11 @@ export const Stepper: React.FC = ({
appendValue
}) => {
return (
-
+
onUpdate(Math.max(min, value - step))}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
@@ -39,6 +46,6 @@ export const Stepper: React.FC = ({
>
+
-
+
)
}
\ No newline at end of file
diff --git a/components/jellyseerr/Cast.tsx b/components/jellyseerr/Cast.tsx
new file mode 100644
index 00000000..8dcdd785
--- /dev/null
+++ b/components/jellyseerr/Cast.tsx
@@ -0,0 +1,41 @@
+import { View, ViewProps } from "react-native";
+import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
+import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
+import React from "react";
+import { FlashList } from "@shopify/flash-list";
+import { Text } from "@/components/common/Text";
+import PersonPoster from "@/components/jellyseerr/PersonPoster";
+import { useTranslation } from "react-i18next";
+
+const CastSlide: React.FC<
+ { details?: MovieDetails | TvDetails } & ViewProps
+> = ({ details, ...props }) => {
+ const { t } = useTranslation();
+ return (
+ details?.credits?.cast &&
+ details?.credits?.cast?.length > 0 && (
+
+ {t("jellyseerr.cast")}
+ }
+ estimatedItemSize={15}
+ keyExtractor={(item) => item?.id?.toString()}
+ contentContainerStyle={{ paddingHorizontal: 16 }}
+ renderItem={({ item }) => (
+
+ )}
+ />
+
+ )
+ );
+};
+
+export default CastSlide;
diff --git a/components/jellyseerr/DetailFacts.tsx b/components/jellyseerr/DetailFacts.tsx
new file mode 100644
index 00000000..0a3f3d03
--- /dev/null
+++ b/components/jellyseerr/DetailFacts.tsx
@@ -0,0 +1,220 @@
+import { View, ViewProps } from "react-native";
+import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
+import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
+import { Text } from "@/components/common/Text";
+import { useMemo } from "react";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { uniqBy } from "lodash";
+import { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
+import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
+import CountryFlag from "react-native-country-flag";
+import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
+import { useTranslation } from "react-i18next";
+
+interface Release {
+ certification: string;
+ iso_639_1?: string;
+ note?: string;
+ release_date: string;
+ type: number;
+}
+
+const dateOpts: Intl.DateTimeFormatOptions = {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+};
+
+const Facts: React.FC<
+ { title: string; facts?: string[] | React.ReactNode[] } & ViewProps
+> = ({ title, facts, ...props }) =>
+ facts &&
+ facts?.length > 0 && (
+
+ {title}
+
+
+ {facts.map((f, idx) =>
+ typeof f === "string" ? {f} : f
+ )}
+
+
+ );
+
+const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
+ title,
+ fact,
+ ...props
+}) => fact && ;
+
+const DetailFacts: React.FC<
+ { details?: MovieDetails | TvDetails } & ViewProps
+> = ({ details, className, ...props }) => {
+ const { jellyseerrUser } = useJellyseerr();
+ const { t } = useTranslation();
+
+ const locale = useMemo(() => {
+ return jellyseerrUser?.settings?.locale || "en";
+ }, [jellyseerrUser]);
+
+ const region = useMemo(
+ () => jellyseerrUser?.settings?.region || "US",
+ [jellyseerrUser]
+ );
+
+ const releases = useMemo(
+ () =>
+ (details as MovieDetails)?.releases?.results.find(
+ (r: TmdbRelease) => r.iso_3166_1 === region
+ )?.release_dates as TmdbRelease["release_dates"],
+ [details]
+ );
+
+ // Release date types:
+ // 1. Premiere
+ // 2. Theatrical (limited)
+ // 3. Theatrical
+ // 4. Digital
+ // 5. Physical
+ // 6. TV
+ const filteredReleases = useMemo(
+ () =>
+ uniqBy(
+ releases?.filter((r: Release) => r.type > 2 && r.type < 6),
+ "type"
+ ),
+ [releases]
+ );
+
+ const firstAirDate = useMemo(() => {
+ const firstAirDate = (details as TvDetails)?.firstAirDate;
+ if (firstAirDate) {
+ return new Date(firstAirDate).toLocaleDateString(
+ `${locale}-${region}`,
+ dateOpts
+ );
+ }
+ }, [details]);
+
+ const nextAirDate = useMemo(() => {
+ const firstAirDate = (details as TvDetails)?.firstAirDate;
+ const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate;
+ if (nextAirDate && firstAirDate !== nextAirDate) {
+ return new Date(nextAirDate).toLocaleDateString(
+ `${locale}-${region}`,
+ dateOpts
+ );
+ }
+ }, [details]);
+
+ const revenue = useMemo(
+ () =>
+ (details as MovieDetails)?.revenue?.toLocaleString?.(
+ `${locale}-${region}`,
+ { style: "currency", currency: "USD" }
+ ),
+ [details]
+ );
+
+ const budget = useMemo(
+ () =>
+ (details as MovieDetails)?.budget?.toLocaleString?.(
+ `${locale}-${region}`,
+ { style: "currency", currency: "USD" }
+ ),
+ [details]
+ );
+
+ const streamingProviders = useMemo(
+ () =>
+ details?.watchProviders?.find(
+ (provider) => provider.iso_3166_1 === region
+ )?.flatrate,
+ [details]
+ );
+
+ const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
+
+ const spokenLanguage = useMemo(
+ () =>
+ details?.spokenLanguages.find(
+ (lng) => lng.iso_639_1 === details.originalLanguage
+ )?.name,
+ [details]
+ );
+
+ return (
+ details && (
+
+ {t("jellyseerr.details")}
+
+
+
+ {details.keywords.some(
+ (keyword) => keyword.id === ANIME_KEYWORD_ID
+ ) && }
+ (
+
+ {r.type === 3 ? (
+ // Theatrical
+
+ ) : r.type === 4 ? (
+ // Digital
+
+ ) : (
+ // Physical
+
+ )}
+
+ {new Date(r.release_date).toLocaleDateString(
+ `${locale}-${region}`,
+ dateOpts
+ )}
+
+
+ ))}
+ />
+
+
+
+
+
+ (
+
+
+ {n.name}
+
+ ))}
+ />
+ n.name
+ )}
+ />
+ n.name)} />
+ s.name)}
+ />
+
+
+ )
+ );
+};
+
+export default DetailFacts;
diff --git a/components/jellyseerr/JellyseerrIndexPage.tsx b/components/jellyseerr/JellyseerrIndexPage.tsx
new file mode 100644
index 00000000..cd093deb
--- /dev/null
+++ b/components/jellyseerr/JellyseerrIndexPage.tsx
@@ -0,0 +1,161 @@
+import Discover from "@/components/jellyseerr/discover/Discover";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { MediaType } from "@/utils/jellyseerr/server/constants/media";
+import {
+ MovieResult,
+ PersonResult,
+ TvResult,
+} from "@/utils/jellyseerr/server/models/Search";
+import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
+import React, { useMemo } from "react";
+import { View, ViewProps } from "react-native";
+import {
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { Text } from "../common/Text";
+import JellyseerrPoster from "../posters/JellyseerrPoster";
+import { LoadingSkeleton } from "../search/LoadingSkeleton";
+import { SearchItemWrapper } from "../search/SearchItemWrapper";
+import PersonPoster from "./PersonPoster";
+import { useTranslation } from "react-i18next";
+
+interface Props extends ViewProps {
+ searchQuery: string;
+}
+
+export const JellyserrIndexPage: React.FC = ({ searchQuery }) => {
+ const { jellyseerrApi } = useJellyseerr();
+ const opacity = useSharedValue(1);
+ const { t } = useTranslation();
+
+ const {
+ data: jellyseerrDiscoverSettings,
+ isFetching: f1,
+ isLoading: l1,
+ } = useReactNavigationQuery({
+ queryKey: ["search", "jellyseerr", "discoverSettings", searchQuery],
+ queryFn: async () => jellyseerrApi?.discoverSettings(),
+ enabled: !!jellyseerrApi && searchQuery.length == 0,
+ });
+
+ const {
+ data: jellyseerrResults,
+ isFetching: f2,
+ isLoading: l2,
+ } = useReactNavigationQuery({
+ queryKey: ["search", "jellyseerr", "results", searchQuery],
+ queryFn: async () => {
+ const response = await jellyseerrApi?.search({
+ query: new URLSearchParams(searchQuery).toString(),
+ page: 1,
+ language: "en",
+ });
+ return response?.results;
+ },
+ enabled: !!jellyseerrApi && searchQuery.length > 0,
+ });
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ opacity: opacity.value,
+ };
+ });
+
+ useAnimatedReaction(
+ () => f1 || f2 || l1 || l2,
+ (isLoading) => {
+ if (isLoading) {
+ opacity.value = withTiming(1, { duration: 200 });
+ } else {
+ opacity.value = withTiming(0, { duration: 200 });
+ }
+ }
+ );
+
+ const jellyseerrMovieResults = useMemo(
+ () =>
+ jellyseerrResults?.filter(
+ (r) => r.mediaType === MediaType.MOVIE
+ ) as MovieResult[],
+ [jellyseerrResults]
+ );
+
+ const jellyseerrTvResults = useMemo(
+ () =>
+ jellyseerrResults?.filter(
+ (r) => r.mediaType === MediaType.TV
+ ) as TvResult[],
+ [jellyseerrResults]
+ );
+
+ const jellyseerrPersonResults = useMemo(
+ () =>
+ jellyseerrResults?.filter(
+ (r) => r.mediaType === "person"
+ ) as PersonResult[],
+ [jellyseerrResults]
+ );
+
+ if (!searchQuery.length)
+ return (
+
+
+
+ );
+
+ return (
+
+
+
+ {!jellyseerrMovieResults?.length &&
+ !jellyseerrTvResults?.length &&
+ !jellyseerrPersonResults?.length &&
+ !f1 &&
+ !f2 &&
+ !l1 &&
+ !l2 && (
+
+
+ {t("search.no_results_found_for")}
+
+
+ "{searchQuery}"
+
+
+ )}
+
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+
+ );
+};
diff --git a/components/jellyseerr/JellyseerrMediaIcon.tsx b/components/jellyseerr/JellyseerrMediaIcon.tsx
new file mode 100644
index 00000000..97a5ab69
--- /dev/null
+++ b/components/jellyseerr/JellyseerrMediaIcon.tsx
@@ -0,0 +1,37 @@
+import {useMemo} from "react";
+import {MediaType} from "@/utils/jellyseerr/server/constants/media";
+import {Feather, MaterialCommunityIcons} from "@expo/vector-icons";
+import {View, ViewProps} from "react-native";
+
+const JellyseerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
+ mediaType,
+ className,
+ ...props
+}) => {
+ const style = useMemo(
+ () => mediaType === MediaType.MOVIE
+ ? 'bg-blue-600/90 border-blue-400/40'
+ : 'bg-purple-600/90 border-purple-400/40',
+ [mediaType]
+ );
+ return (
+ mediaType &&
+
+ {mediaType === MediaType.MOVIE ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export default JellyseerrMediaIcon;
\ No newline at end of file
diff --git a/components/icons/JellyseerrIconStatus.tsx b/components/jellyseerr/JellyseerrStatusIcon.tsx
similarity index 93%
rename from components/icons/JellyseerrIconStatus.tsx
rename to components/jellyseerr/JellyseerrStatusIcon.tsx
index 4c1bda37..8fc593fa 100644
--- a/components/icons/JellyseerrIconStatus.tsx
+++ b/components/jellyseerr/JellyseerrStatusIcon.tsx
@@ -2,7 +2,6 @@ import {useEffect, useState} from "react";
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
import {MaterialCommunityIcons} from "@expo/vector-icons";
import {TouchableOpacity, View, ViewProps} from "react-native";
-import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
interface Props {
mediaStatus?: MediaStatus;
@@ -10,7 +9,7 @@ interface Props {
onPress?: () => void;
}
-const JellyseerrIconStatus: React.FC = ({
+const JellyseerrStatusIcon: React.FC = ({
mediaStatus,
showRequestIcon,
onPress,
@@ -69,4 +68,4 @@ const JellyseerrIconStatus: React.FC = ({
)
}
-export default JellyseerrIconStatus;
\ No newline at end of file
+export default JellyseerrStatusIcon;
\ No newline at end of file
diff --git a/components/jellyseerr/ParallaxSlideShow.tsx b/components/jellyseerr/ParallaxSlideShow.tsx
new file mode 100644
index 00000000..6a7fcb7f
--- /dev/null
+++ b/components/jellyseerr/ParallaxSlideShow.tsx
@@ -0,0 +1,160 @@
+import React, {
+ PropsWithChildren,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import {Dimensions, View, ViewProps} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { ParallaxScrollView } from "@/components/ParallaxPage";
+import { Text } from "@/components/common/Text";
+import { Animated } from "react-native";
+import { FlashList } from "@shopify/flash-list";
+import {useFocusEffect} from "expo-router";
+
+const ANIMATION_ENTER = 250;
+const ANIMATION_EXIT = 250;
+const BACKDROP_DURATION = 5000;
+
+type Render = React.ComponentType
+ | React.ReactElement
+ | null
+ | undefined;
+
+interface Props {
+ data: T[]
+ images: string[];
+ logo?: React.ReactElement;
+ HeaderContent?: () => React.ReactElement;
+ MainContent?: () => React.ReactElement;
+ listHeader: string;
+ renderItem: (item: T, index: number) => Render;
+ keyExtractor: (item: T) => string;
+ onEndReached?: (() => void) | null | undefined;
+}
+
+const ParallaxSlideShow = ({
+ data,
+ images,
+ logo,
+ HeaderContent,
+ MainContent,
+ listHeader,
+ renderItem,
+ keyExtractor,
+ onEndReached,
+ ...props
+}: PropsWithChildren & ViewProps>
+) => {
+ const insets = useSafeAreaInsets();
+
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+
+ const enterAnimation = useCallback(
+ () =>
+ Animated.timing(fadeAnim, {
+ toValue: 1,
+ duration: ANIMATION_ENTER,
+ useNativeDriver: true,
+ }),
+ [fadeAnim]
+ );
+
+ const exitAnimation = useCallback(
+ () =>
+ Animated.timing(fadeAnim, {
+ toValue: 0,
+ duration: ANIMATION_EXIT,
+ useNativeDriver: true,
+ }),
+ [fadeAnim]
+ );
+
+ useEffect(() => {
+ if (images?.length) {
+ enterAnimation().start();
+
+ const intervalId = setInterval(() => {
+ Animated.sequence([
+ enterAnimation(),
+ exitAnimation()
+ ]).start(() => {
+ fadeAnim.setValue(0);
+ setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
+ })
+ }, BACKDROP_DURATION);
+
+ return () => {
+ clearInterval(intervalId)
+ };
+ }
+ }, [fadeAnim, images, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
+
+ return (
+
+
+ }
+ logo={logo}
+ >
+
+
+
+ {HeaderContent && HeaderContent()}
+
+
+ {MainContent && MainContent()}
+
+
+
+ No results
+
+
+ }
+ contentInsetAdjustmentBehavior="automatic"
+ ListHeaderComponent={
+ {listHeader}
+ }
+ nestedScrollEnabled
+ showsVerticalScrollIndicator={false}
+ //@ts-ignore
+ renderItem={({ item, index}) => renderItem(item, index)}
+ keyExtractor={keyExtractor}
+ numColumns={3}
+ estimatedItemSize={214}
+ ItemSeparatorComponent={() => }
+ />
+
+
+
+
+ );
+}
+
+export default ParallaxSlideShow;
\ No newline at end of file
diff --git a/components/jellyseerr/PersonPoster.tsx b/components/jellyseerr/PersonPoster.tsx
new file mode 100644
index 00000000..6e7d9aa6
--- /dev/null
+++ b/components/jellyseerr/PersonPoster.tsx
@@ -0,0 +1,42 @@
+import {TouchableOpacity, View, ViewProps} from "react-native";
+import React from "react";
+import {Text} from "@/components/common/Text";
+import Poster from "@/components/posters/Poster";
+import {useRouter, useSegments} from "expo-router";
+import {useJellyseerr} from "@/hooks/useJellyseerr";
+
+interface Props {
+ id: string
+ posterPath?: string
+ name: string
+ subName?: string
+}
+
+const PersonPoster: React.FC = ({
+ id,
+ posterPath,
+ name,
+ subName,
+ ...props
+}) => {
+ const {jellyseerrApi} = useJellyseerr();
+ const router = useRouter();
+ const segments = useSegments();
+ const from = segments[2];
+
+ if (from === "(home)" || from === "(search)" || from === "(libraries)")
+ return (
+ router.push(`/(auth)/(tabs)/${from}/jellyseerr/person/${id}`)}>
+
+
+ {name}
+ {subName && {subName}}
+
+
+ )
+}
+
+export default PersonPoster;
\ No newline at end of file
diff --git a/components/jellyseerr/RequestModal.tsx b/components/jellyseerr/RequestModal.tsx
new file mode 100644
index 00000000..1f7180e7
--- /dev/null
+++ b/components/jellyseerr/RequestModal.tsx
@@ -0,0 +1,236 @@
+import React, {forwardRef, useCallback, useMemo, useState} from "react";
+import {View, ViewProps} from "react-native";
+import {useJellyseerr} from "@/hooks/useJellyseerr";
+import {useQuery} from "@tanstack/react-query";
+import {MediaType} from "@/utils/jellyseerr/server/constants/media";
+import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
+import Dropdown from "@/components/common/Dropdown";
+import {QualityProfile, RootFolder, Tag} from "@/utils/jellyseerr/server/api/servarr/base";
+import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
+import {BottomSheetModalMethods} from "@gorhom/bottom-sheet/lib/typescript/types";
+import {Button} from "@/components/Button";
+import {Text} from "@/components/common/Text";
+import { useTranslation } from "react-i18next";
+
+interface Props {
+ id: number;
+ title: string,
+ type: MediaType;
+ isAnime?: boolean;
+ is4k?: boolean;
+ onRequested?: () => void;
+}
+
+const RequestModal = forwardRef>(({
+ id,
+ title,
+ type,
+ isAnime = false,
+ onRequested,
+ ...props
+}, ref) => {
+ const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr();
+ const [requestOverrides, setRequestOverrides] =
+ useState({
+ mediaId: Number(id),
+ mediaType: type,
+ userId: jellyseerrUser?.id
+ });
+
+ const { t } = useTranslation();
+
+ const [modalRequestProps, setModalRequestProps] = useState();
+
+ const {data: serviceSettings} = useQuery({
+ queryKey: ["jellyseerr", "request", type, 'service'],
+ queryFn: async () => jellyseerrApi?.service(type == 'movie' ? 'radarr' : 'sonarr'),
+ enabled: !!jellyseerrApi && !!jellyseerrUser,
+ refetchOnMount: 'always'
+ });
+
+ const {data: users} = useQuery({
+ queryKey: ["jellyseerr", "users"],
+ queryFn: async () => jellyseerrApi?.user({take: 1000, sort: 'displayname'}),
+ enabled: !!jellyseerrApi && !!jellyseerrUser,
+ refetchOnMount: 'always'
+ });
+
+ const defaultService = useMemo(
+ () => serviceSettings?.find?.(v => v.isDefault),
+ [serviceSettings]
+ );
+
+ const {data: defaultServiceDetails} = useQuery({
+ queryKey: ["jellyseerr", "request", type, 'service', 'details', defaultService?.id],
+ queryFn: async () => {
+ setRequestOverrides((prev) => ({
+ ...prev,
+ serverId: defaultService?.id
+ }))
+ return jellyseerrApi?.serviceDetails(type === 'movie' ? 'radarr' : 'sonarr', defaultService!!.id)
+ },
+ enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService,
+ refetchOnMount: 'always',
+ });
+
+ const defaultProfile: QualityProfile = useMemo(
+ () => defaultServiceDetails?.profiles
+ .find(p =>
+ p.id === (isAnime ? defaultServiceDetails.server?.activeAnimeProfileId : defaultServiceDetails.server?.activeProfileId)
+ ),
+ [defaultServiceDetails]
+ );
+
+ const defaultFolder: RootFolder = useMemo(
+ () => defaultServiceDetails?.rootFolders
+ .find(f =>
+ f.path === (isAnime ? defaultServiceDetails?.server.activeAnimeDirectory : defaultServiceDetails.server?.activeDirectory)
+ ),
+ [defaultServiceDetails]
+ );
+
+ const defaultTags: Tag[] = useMemo(
+ () => {
+ const tags = defaultServiceDetails?.tags
+ .filter(t =>
+ (isAnime
+ ? defaultServiceDetails?.server.activeAnimeTags
+ : defaultServiceDetails?.server.activeTags
+ )?.includes(t.id)
+ ) ?? []
+
+ console.log(tags)
+ return tags
+ },
+ [defaultServiceDetails]
+ );
+
+ const seasonTitle = useMemo(
+ () => modalRequestProps?.seasons?.length ? t("jellyseerr.season_x", {seasons: modalRequestProps?.seasons}) : undefined,
+ [modalRequestProps?.seasons]
+ );
+
+ const request = useCallback(() => {requestMedia(
+ seasonTitle ? `${title}, ${seasonTitle}` : title,
+ {
+ is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
+ profileId: defaultProfile.id,
+ rootFolder: defaultFolder.path,
+ tags: defaultTags.map(t => t.id),
+ ...modalRequestProps,
+ ...requestOverrides
+ },
+ onRequested
+ )
+ }, [requestOverrides, defaultProfile, defaultFolder, defaultTags]);
+
+ const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`;
+
+ return (
+ setModalRequestProps(undefined)}
+ handleIndicatorStyle={{
+ backgroundColor: "white",
+ }}
+ backgroundStyle={{
+ backgroundColor: "#171717",
+ }}
+ backdropComponent={(sheetProps: BottomSheetBackdropProps) =>
+
+ }
+ >
+ {(data) => {
+ setModalRequestProps(data?.data as MediaRequestBody)
+ return
+
+
+ {t("jellyseerr.advanced")}
+ {seasonTitle &&
+ {seasonTitle}
+ }
+
+
+ {(defaultService && defaultServiceDetails && users) && (
+ <>
+ 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")}
+ />
+ item.id.toString()}
+ label={t("jellyseerr.root_folder")}
+ onSelected={(item) =>
+ item && setRequestOverrides((prev) => ({
+ ...prev,
+ rootFolder: item.path
+ }))}
+ title={t("jellyseerr.root_folder")}
+ />
+ 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")}
+ />
+ 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")}
+ />
+ >
+ )
+ }
+
+
+
+
+ }}
+
+ );
+});
+
+export default RequestModal;
\ No newline at end of file
diff --git a/components/jellyseerr/discover/CompanySlide.tsx b/components/jellyseerr/discover/CompanySlide.tsx
new file mode 100644
index 00000000..abee4a9d
--- /dev/null
+++ b/components/jellyseerr/discover/CompanySlide.tsx
@@ -0,0 +1,51 @@
+import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
+import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import {
+ COMPANY_LOGO_IMAGE_FILTER,
+ Network,
+} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
+import { Studio } from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
+import { router, useSegments } from "expo-router";
+import React, { useCallback } from "react";
+import { TouchableOpacity, ViewProps } from "react-native";
+
+const CompanySlide: React.FC<
+ { data: Network[] | Studio[] } & SlideProps & ViewProps
+> = ({ slide, data, ...props }) => {
+ const segments = useSegments();
+ const { jellyseerrApi } = useJellyseerr();
+ const from = segments[2];
+
+ const navigate = useCallback(
+ ({ id, image, name }: Network | Studio) =>
+ router.push({
+ pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
+ params: { id, image, name, type: slide.type },
+ }),
+ [slide]
+ );
+
+ return (
+ item.id.toString()}
+ renderItem={(item, index) => (
+ navigate(item)}>
+
+
+ )}
+ />
+ );
+};
+
+export default CompanySlide;
diff --git a/components/jellyseerr/discover/Discover.tsx b/components/jellyseerr/discover/Discover.tsx
new file mode 100644
index 00000000..6270ad2b
--- /dev/null
+++ b/components/jellyseerr/discover/Discover.tsx
@@ -0,0 +1,47 @@
+import React, {useMemo} from "react";
+import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
+import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
+import {sortBy} from "lodash";
+import MovieTvSlide from "@/components/jellyseerr/discover/MovieTvSlide";
+import CompanySlide from "@/components/jellyseerr/discover/CompanySlide";
+import {View} from "react-native";
+import {networks} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
+import {studios} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
+import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
+
+interface Props {
+ sliders?: DiscoverSlider[];
+}
+const Discover: React.FC = ({ sliders }) => {
+ if (!sliders)
+ return;
+
+ const sortedSliders = useMemo(
+ () => sortBy(sliders.filter((s) => s.enabled), 'order', 'asc'),
+ [sliders]
+ );
+
+ return (
+
+ {sortedSliders.map(slide => {
+ switch (slide.type) {
+ case DiscoverSliderType.NETWORKS:
+ return
+ case DiscoverSliderType.STUDIOS:
+ return
+ case DiscoverSliderType.MOVIE_GENRES:
+ case DiscoverSliderType.TV_GENRES:
+ return
+ case DiscoverSliderType.TRENDING:
+ case DiscoverSliderType.POPULAR_MOVIES:
+ case DiscoverSliderType.UPCOMING_MOVIES:
+ case DiscoverSliderType.POPULAR_TV:
+ case DiscoverSliderType.UPCOMING_TV:
+ return
+ }
+ })}
+
+ )
+};
+
+export default Discover;
diff --git a/components/jellyseerr/discover/GenericSlideCard.tsx b/components/jellyseerr/discover/GenericSlideCard.tsx
new file mode 100644
index 00000000..776d1424
--- /dev/null
+++ b/components/jellyseerr/discover/GenericSlideCard.tsx
@@ -0,0 +1,59 @@
+import React from "react";
+import {StyleSheet, View, ViewProps} from "react-native";
+import {Image, ImageContentFit} from "expo-image";
+import {Text} from "@/components/common/Text";
+import {LinearGradient} from "expo-linear-gradient";
+
+export const textShadowStyle = StyleSheet.create({
+ shadow: {
+ shadowColor: "#000",
+ shadowOffset: {
+ width: 1,
+ height: 1,
+ },
+ shadowOpacity: 1,
+ shadowRadius: .5,
+
+ elevation: 6,
+ }
+})
+
+const GenericSlideCard: React.FC<{id: string; url?: string, title?: string, colors?: string[], contentFit?: ImageContentFit} & ViewProps> = ({
+ id,
+ url,
+ title,
+ colors = ['#9333ea', 'transparent'],
+ contentFit = "contain",
+ ...props
+}) => (
+ <>
+
+
+
+ {title &&
+
+ {title}
+
+ }
+
+
+ >
+);
+
+export default GenericSlideCard;
\ No newline at end of file
diff --git a/components/jellyseerr/discover/GenreSlide.tsx b/components/jellyseerr/discover/GenreSlide.tsx
new file mode 100644
index 00000000..36623e20
--- /dev/null
+++ b/components/jellyseerr/discover/GenreSlide.tsx
@@ -0,0 +1,67 @@
+import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
+import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide";
+import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
+import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
+import { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
+import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
+import { useQuery } from "@tanstack/react-query";
+import { router, useSegments } from "expo-router";
+import React, { useCallback } from "react";
+import { TouchableOpacity, ViewProps } from "react-native";
+
+const GenreSlide: React.FC = ({ slide, ...props }) => {
+ const segments = useSegments();
+ const { jellyseerrApi } = useJellyseerr();
+ const from = segments[2];
+
+ const navigate = useCallback(
+ (genre: GenreSliderItem) =>
+ router.push({
+ pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`,
+ params: { type: slide.type, name: genre.name },
+ }),
+ [slide]
+ );
+
+ const { data, isFetching, isLoading } = useQuery({
+ queryKey: ["jellyseerr", "discover", slide.type, slide.id],
+ queryFn: async () => {
+ return jellyseerrApi?.getGenreSliders(
+ slide.type == DiscoverSliderType.MOVIE_GENRES
+ ? Endpoints.MOVIE
+ : Endpoints.TV
+ );
+ },
+ enabled: !!jellyseerrApi,
+ });
+
+ return (
+ data && (
+ item.id.toString()}
+ renderItem={(item, index) => (
+ navigate(item)}>
+
+
+ )}
+ />
+ )
+ );
+};
+
+export default GenreSlide;
diff --git a/components/jellyseerr/DiscoverSlide.tsx b/components/jellyseerr/discover/MovieTvSlide.tsx
similarity index 58%
rename from components/jellyseerr/DiscoverSlide.tsx
rename to components/jellyseerr/discover/MovieTvSlide.tsx
index c7112def..723658c8 100644
--- a/components/jellyseerr/DiscoverSlide.tsx
+++ b/components/jellyseerr/discover/MovieTvSlide.tsx
@@ -1,5 +1,4 @@
-import React, { useMemo } from "react";
-import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
+import React, {useMemo} from "react";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
DiscoverEndpoint,
@@ -9,17 +8,13 @@ import {
import { useInfiniteQuery } from "@tanstack/react-query";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
-import { Text } from "@/components/common/Text";
-import { FlashList } from "@shopify/flash-list";
-import { View } from "react-native";
+import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
+import {ViewProps} from "react-native";
-interface Props {
- slide: DiscoverSlider;
-}
-const DiscoverSlide: React.FC = ({ slide }) => {
+const MovieTvSlide: React.FC = ({ slide, ...props }) => {
const { jellyseerrApi } = useJellyseerr();
- const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
+ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "discover", slide.id],
queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined = undefined;
@@ -62,42 +57,28 @@ const DiscoverSlide: React.FC = ({ slide }) => {
});
const flatData = useMemo(
- () =>
- data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
+ () => data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
[data]
);
return (
flatData &&
flatData?.length > 0 && (
-
-
- {DiscoverSliderType[slide.type].toString().toTitle()}
-
- item!!.id.toString()}
- estimatedItemSize={250}
- data={flatData}
- onEndReachedThreshold={1}
- onEndReached={() => {
- if (hasNextPage) fetchNextPage();
- }}
- renderItem={({ item }) =>
- item ? (
-
- ) : (
- <>>
- )
- }
- />
-
+ item!!.id.toString()}
+ onEndReached={() => {
+ if (hasNextPage)
+ fetchNextPage()
+ }}
+ renderItem={(item) =>
+
+ }
+ />
)
);
};
-export default DiscoverSlide;
+export default MovieTvSlide;
diff --git a/components/jellyseerr/discover/Slide.tsx b/components/jellyseerr/discover/Slide.tsx
new file mode 100644
index 00000000..f110eb15
--- /dev/null
+++ b/components/jellyseerr/discover/Slide.tsx
@@ -0,0 +1,56 @@
+import React, {PropsWithChildren} from "react";
+import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
+import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
+import { Text } from "@/components/common/Text";
+import { FlashList } from "@shopify/flash-list";
+import {View, ViewProps} from "react-native";
+import { t } from "i18next";
+
+export interface SlideProps {
+ slide: DiscoverSlider;
+}
+
+interface Props extends SlideProps {
+ data: T[]
+ renderItem: (item: T, index: number) =>
+ | React.ComponentType
+ | React.ReactElement
+ | null
+ | undefined;
+ keyExtractor: (item: T) => string;
+ onEndReached?: (() => void) | null | undefined;
+}
+
+const Slide = ({
+ data,
+ slide,
+ renderItem,
+ keyExtractor,
+ onEndReached,
+ ...props
+}: PropsWithChildren & ViewProps>
+) => {
+ return (
+
+
+ {t("search." + DiscoverSliderType[slide.type].toString().toLowerCase())}
+
+ item ? renderItem(item, index) : <>>}
+ />
+
+ );
+};
+
+export default Slide;
diff --git a/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx
index 17595db6..a7b78f0a 100644
--- a/components/library/LibraryItemCard.tsx
+++ b/components/library/LibraryItemCard.tsx
@@ -15,6 +15,7 @@ import { useAtom } from "jotai";
import { useMemo } from "react";
import { TouchableOpacityProps, View } from "react-native";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
+import { useTranslation } from "react-i18next";
interface Props extends TouchableOpacityProps {
library: BaseItemDto;
@@ -42,6 +43,8 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => {
const [user] = useAtom(userAtom);
const [settings] = useSettings();
+ const { t } = useTranslation();
+
const url = useMemo(
() =>
getPrimaryImageUrl({
@@ -60,8 +63,6 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => {
_itemType = "Series";
} else if (library.CollectionType === "boxsets") {
_itemType = "BoxSet";
- } else if (library.CollectionType === "music") {
- _itemType = "MusicAlbum";
}
return _itemType;
@@ -71,15 +72,13 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => {
let nameStr: string;
if (library.CollectionType === "movies") {
- nameStr = "movies";
+ nameStr = t("library.item_types.movies");
} else if (library.CollectionType === "tvshows") {
- nameStr = "series";
+ nameStr = t("library.item_types.series");
} else if (library.CollectionType === "boxsets") {
- nameStr = "box sets";
- } else if (library.CollectionType === "music") {
- nameStr = "albums";
+ nameStr = t("library.item_types.boxsets");
} else {
- nameStr = "items";
+ nameStr = t("library.item_types.items");
}
return nameStr;
diff --git a/components/list/ListItem.tsx b/components/list/ListItem.tsx
index 46856b00..403b33dc 100644
--- a/components/list/ListItem.tsx
+++ b/components/list/ListItem.tsx
@@ -1,3 +1,4 @@
+import { Ionicons } from "@expo/vector-icons";
import { PropsWithChildren, ReactNode } from "react";
import {
TouchableOpacity,
@@ -6,7 +7,6 @@ import {
ViewProps,
} from "react-native";
import { Text } from "../common/Text";
-import { Ionicons } from "@expo/vector-icons";
interface Props extends TouchableOpacityProps, ViewProps {
title?: string | null | undefined;
diff --git a/components/music/SongsList.tsx b/components/music/SongsList.tsx
deleted file mode 100644
index 4d576f3c..00000000
--- a/components/music/SongsList.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useRouter } from "expo-router";
-import { View, ViewProps } from "react-native";
-import { SongsListItem } from "./SongsListItem";
-
-interface Props extends ViewProps {
- songs?: BaseItemDto[] | null;
- collectionId: string;
- artistId: string;
- albumId: string;
-}
-
-export const SongsList: React.FC = ({
- collectionId,
- artistId,
- albumId,
- songs = [],
- ...props
-}) => {
- const router = useRouter();
- return (
-
- {songs?.map((item: BaseItemDto, index: number) => (
-
- ))}
-
- );
-};
diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx
deleted file mode 100644
index 552baa69..00000000
--- a/components/music/SongsListItem.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { Text } from "@/components/common/Text";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { usePlaySettings } from "@/providers/PlaySettingsProvider";
-import { runtimeTicksToSeconds } from "@/utils/time";
-import { useActionSheet } from "@expo/react-native-action-sheet";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useRouter } from "expo-router";
-import { useAtom } from "jotai";
-import { useCallback } from "react";
-import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
-import CastContext, {
- PlayServicesState,
- useCastDevice,
- useRemoteMediaClient,
-} from "react-native-google-cast";
-
-interface Props extends TouchableOpacityProps {
- collectionId: string;
- artistId: string;
- albumId: string;
- item: BaseItemDto;
- index: number;
-}
-
-export const SongsListItem: React.FC = ({
- collectionId,
- artistId,
- albumId,
- item,
- index,
- ...props
-}) => {
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
- const castDevice = useCastDevice();
- const router = useRouter();
- const client = useRemoteMediaClient();
- const { showActionSheetWithOptions } = useActionSheet();
-
- const { setPlaySettings } = usePlaySettings();
-
- const openSelect = () => {
- if (!castDevice?.deviceId) {
- play("device");
- return;
- }
-
- const options = ["Chromecast", "Device", "Cancel"];
- const cancelButtonIndex = 2;
-
- showActionSheetWithOptions(
- {
- options,
- cancelButtonIndex,
- },
- (selectedIndex: number | undefined) => {
- switch (selectedIndex) {
- case 0:
- play("cast");
- break;
- case 1:
- play("device");
- break;
- case cancelButtonIndex:
- break;
- }
- }
- );
- };
-
- const play = useCallback(async (type: "device" | "cast") => {
- if (!user?.Id || !api || !item.Id) {
- console.warn("No user, api or item", user, api, item.Id);
- return;
- }
-
- const data = await setPlaySettings({
- item,
- });
-
- if (!data?.url) {
- throw new Error("play-music ~ No stream url");
- }
-
- if (type === "cast" && client) {
- await CastContext.getPlayServicesState().then((state) => {
- if (state && state !== PlayServicesState.SUCCESS)
- CastContext.showPlayServicesErrorDialog(state);
- else {
- client.loadMedia({
- mediaInfo: {
- contentUrl: data.url!,
- contentType: "video/mp4",
- metadata: {
- type: item.Type === "Episode" ? "tvShow" : "movie",
- title: item.Name || "",
- subtitle: item.Overview || "",
- },
- },
- startTime: 0,
- });
- }
- });
- } else {
- console.log("Playing on device", data.url, item.Id);
- router.push("/music-player");
- }
- }, []);
-
- return (
- {
- openSelect();
- }}
- {...props}
- >
-
- {index + 1}
-
- {item.Name}
-
- {runtimeTicksToSeconds(item.RunTimeTicks)}
-
-
-
-
- );
-};
diff --git a/components/posters/AlbumCover.tsx b/components/posters/AlbumCover.tsx
deleted file mode 100644
index 870dce6a..00000000
--- a/components/posters/AlbumCover.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { apiAtom } from "@/providers/JellyfinProvider";
-import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
-import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo } from "react";
-import { View } from "react-native";
-
-type ArtistPosterProps = {
- item?: BaseItemDto | null;
- id?: string | null;
- showProgress?: boolean;
-};
-
-const AlbumCover: React.FC = ({ item, id }) => {
- const [api] = useAtom(apiAtom);
-
- const url = useMemo(() => {
- const u = getPrimaryImageUrl({
- api,
- item,
- });
- return u;
- }, [item]);
-
- const url2 = useMemo(() => {
- const u = getPrimaryImageUrlById({
- api,
- id,
- quality: 85,
- width: 300,
- });
- return u;
- }, [item]);
-
- if (!item && id)
- return (
-
-
-
- );
-
- if (item)
- return (
-
-
-
- );
-};
-
-export default AlbumCover;
diff --git a/components/posters/ArtistPoster.tsx b/components/posters/ArtistPoster.tsx
deleted file mode 100644
index d64818b6..00000000
--- a/components/posters/ArtistPoster.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { apiAtom } from "@/providers/JellyfinProvider";
-import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo } from "react";
-import { View } from "react-native";
-
-type ArtistPosterProps = {
- item: BaseItemDto;
- showProgress?: boolean;
-};
-
-const ArtistPoster: React.FC = ({
- item,
- showProgress = false,
-}) => {
- const [api] = useAtom(apiAtom);
-
- const url = useMemo(
- () =>
- getPrimaryImageUrl({
- api,
- item,
- }),
- [item]
- );
-
- if (!url)
- return (
-
- );
-
- return (
-
-
-
- );
-};
-
-export default ArtistPoster;
diff --git a/components/posters/JellyseerrPoster.tsx b/components/posters/JellyseerrPoster.tsx
index 5a9647ae..1c3ce45b 100644
--- a/components/posters/JellyseerrPoster.tsx
+++ b/components/posters/JellyseerrPoster.tsx
@@ -1,55 +1,63 @@
-import {View, ViewProps} from "react-native";
-import {Image} from "expo-image";
-import {MaterialCommunityIcons} from "@expo/vector-icons";
-import {Text} from "@/components/common/Text";
-import {useEffect, useMemo, useState} from "react";
-import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
-import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
-import {useJellyseerr} from "@/hooks/useJellyseerr";
-import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
-import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
-import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
+import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
+import { Text } from "@/components/common/Text";
+import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
+import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
+import { MediaType } from "@/utils/jellyseerr/server/constants/media";
+import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
+import { Image } from "expo-image";
+import { useMemo } from "react";
+import { View, ViewProps } from "react-native";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+
interface Props extends ViewProps {
item: MovieResult | TvResult;
}
-const JellyseerrPoster: React.FC = ({
- item,
- ...props
-}) => {
- const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
- // const imageSource =
+const JellyseerrPoster: React.FC = ({ item, ...props }) => {
+ const { jellyseerrApi } = useJellyseerr();
+ const loadingOpacity = useSharedValue(1);
+ const imageOpacity = useSharedValue(0);
- const imageSrc = useMemo(() =>
- item.posterPath ?
- `https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}`
- : jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`,
+ const loadingAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: loadingOpacity.value,
+ }));
+
+ const imageAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: imageOpacity.value,
+ }));
+
+ const handleImageLoad = () => {
+ loadingOpacity.value = withTiming(0, { duration: 200 });
+ imageOpacity.value = withTiming(1, { duration: 300 });
+ };
+
+ const imageSrc = useMemo(
+ () => jellyseerrApi?.imageProxy(item.posterPath, "w300_and_h450_face"),
[item, jellyseerrApi]
- )
- const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item])
- const releaseYear = useMemo(() =>
- new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(),
+ );
+
+ const title = useMemo(
+ () => (item.mediaType === MediaType.MOVIE ? item.title : item.name),
[item]
- )
+ );
- const showRequestButton = useMemo(() =>
- jellyseerrUser && hasPermission(
- [
- Permission.REQUEST,
- item.mediaType === 'movie'
- ? Permission.REQUEST_MOVIE
- : Permission.REQUEST_TV,
- ],
- jellyseerrUser.permissions,
- {type: 'or'}
- ),
- [item, jellyseerrUser]
- )
+ const releaseYear = useMemo(
+ () =>
+ new Date(
+ item.mediaType === MediaType.MOVIE
+ ? item.releaseDate
+ : item.firstAirDate
+ ).getFullYear(),
+ [item]
+ );
- const canRequest = useMemo(() => {
- const status = item?.mediaInfo?.status
- return showRequestButton && !status || status === MediaStatus.UNKNOWN
- }, [item])
+ const [canRequest] = useJellyseerrCanRequest(item);
return (
= ({
mediaTitle={title}
releaseYear={releaseYear}
canRequest={canRequest}
- posterSrc={imageSrc}
+ posterSrc={imageSrc!!}
>
-
-
+
+
+
-
+
{title}
- {releaseYear}
+ {releaseYear}
- )
-}
+ );
+};
-
-export default JellyseerrPoster;
\ No newline at end of file
+export default JellyseerrPoster;
diff --git a/components/posters/Poster.tsx b/components/posters/Poster.tsx
index 1787506e..68799f47 100644
--- a/components/posters/Poster.tsx
+++ b/components/posters/Poster.tsx
@@ -1,19 +1,15 @@
-import {
- BaseItemDto,
- BaseItemPerson,
-} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { View } from "react-native";
type PosterProps = {
- item?: BaseItemDto | BaseItemPerson | null;
+ id?: string | null;
url?: string | null;
showProgress?: boolean;
blurhash?: string | null;
};
-const Poster: React.FC = ({ item, url, blurhash }) => {
- if (!item)
+const Poster: React.FC = ({ id, url, blurhash }) => {
+ if (!id && !url)
return (
= ({ item, url, blurhash }) => {
}
: null
}
- key={item.Id}
- id={item.Id}
+ key={id}
+ id={id!!}
source={
url
? {
diff --git a/components/search/LoadingSkeleton.tsx b/components/search/LoadingSkeleton.tsx
new file mode 100644
index 00000000..8ac38ada
--- /dev/null
+++ b/components/search/LoadingSkeleton.tsx
@@ -0,0 +1,66 @@
+import { View } from "react-native";
+import { Text } from "../common/Text";
+import Animated, {
+ useAnimatedStyle,
+ useAnimatedReaction,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+
+interface Props {
+ isLoading: boolean;
+}
+
+export const LoadingSkeleton: React.FC = ({ isLoading }) => {
+ const opacity = useSharedValue(1);
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ opacity: opacity.value,
+ };
+ });
+
+ useAnimatedReaction(
+ () => isLoading,
+ (loading) => {
+ if (loading) {
+ opacity.value = withTiming(1, { duration: 200 });
+ } else {
+ opacity.value = withTiming(0, { duration: 200 });
+ }
+ }
+ );
+
+ return (
+
+ {[1, 2, 3].map((s) => (
+
+
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+ Nisi mollit voluptate amet.
+
+
+
+
+ Lorem ipsum
+
+
+
+ ))}
+
+
+ ))}
+
+ );
+};
diff --git a/components/search/SearchItemWrapper.tsx b/components/search/SearchItemWrapper.tsx
new file mode 100644
index 00000000..45c3e341
--- /dev/null
+++ b/components/search/SearchItemWrapper.tsx
@@ -0,0 +1,70 @@
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import { useQuery } from "@tanstack/react-query";
+import { useAtom } from "jotai";
+import { PropsWithChildren } from "react";
+import { ScrollView } from "react-native";
+import { Text } from "../common/Text";
+
+type SearchItemWrapperProps = {
+ ids?: string[] | null;
+ items?: T[];
+ renderItem: (item: any) => React.ReactNode;
+ header?: string;
+};
+
+export const SearchItemWrapper = ({
+ ids,
+ items,
+ renderItem,
+ header,
+}: PropsWithChildren>) => {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+
+ const { data, isLoading: l1 } = useQuery({
+ queryKey: ["items", ids],
+ queryFn: async () => {
+ if (!user?.Id || !api || !ids || ids.length === 0) {
+ return [];
+ }
+
+ const itemPromises = ids.map((id) =>
+ getUserItemData({
+ api,
+ userId: user.Id,
+ itemId: id,
+ })
+ );
+
+ const results = await Promise.all(itemPromises);
+
+ // Filter out null items
+ return results.filter(
+ (item) => item !== null
+ ) as unknown as BaseItemDto[];
+ },
+ enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
+ staleTime: Infinity,
+ });
+
+ if (!data && (!items || items.length === 0)) return null;
+
+ return (
+ <>
+ {header}
+
+ {data && data?.length > 0
+ ? data.map((item) => renderItem(item))
+ : items && items?.length > 0
+ ? items.map((i) => renderItem(i))
+ : undefined}
+
+ >
+ );
+};
diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx
index 2b312f0e..e774b561 100644
--- a/components/series/CastAndCrew.tsx
+++ b/components/series/CastAndCrew.tsx
@@ -12,6 +12,7 @@ import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import Poster from "../posters/Poster";
import { itemRouter } from "../common/TouchableItemRouter";
+import { useTranslation } from "react-i18next";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -21,6 +22,7 @@ interface Props extends ViewProps {
export const CastAndCrew: React.FC = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
const segments = useSegments();
+ const { t } = useTranslation();
const from = segments[2];
const destinctPeople = useMemo(() => {
@@ -40,7 +42,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => {
return (
- Cast & Crew
+ {t("item_card.cast_and_crew")}
i.Id.toString()}
@@ -55,7 +57,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => {
}}
className="flex flex-col w-28"
>
-
+
{i.Name}
{i.Role}
diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx
index e573929a..16536a6d 100644
--- a/components/series/CurrentSeries.tsx
+++ b/components/series/CurrentSeries.tsx
@@ -8,6 +8,7 @@ import Poster from "../posters/Poster";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
+import { useTranslation } from "react-i18next";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -15,10 +16,11 @@ interface Props extends ViewProps {
export const CurrentSeries: React.FC = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
+ const { t } = useTranslation();
return (
- Series
+ {t("item_card.series")}
= ({ item, ...props }) => {
className="flex flex-col space-y-2 w-28"
>
{item.SeriesName}
diff --git a/components/series/JellyseerrSeasons.tsx b/components/series/JellyseerrSeasons.tsx
index bcd9b336..320043b2 100644
--- a/components/series/JellyseerrSeasons.tsx
+++ b/components/series/JellyseerrSeasons.tsx
@@ -5,7 +5,7 @@ import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { FlashList } from "@shopify/flash-list";
import { orderBy } from "lodash";
import { Tags } from "@/components/GenreTags";
-import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
+import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import Season from "@/utils/jellyseerr/server/entity/Season";
import {
MediaStatus,
@@ -20,7 +20,9 @@ import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Image } from "expo-image";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import { Loader } from "../Loader";
+import { t } from "i18next";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
+import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
const JellyseerrSeasonEpisodes: React.FC<{
details: TvDetails;
@@ -61,7 +63,7 @@ const RenderItem = ({ item, index }: any) => {
key={item.id}
id={item.id}
source={{
- uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
+ uri: jellyseerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
@@ -101,8 +103,17 @@ const JellyseerrSeasons: React.FC<{
isLoading: boolean;
result?: TvResult;
details?: TvDetails;
+ hasAdvancedRequest?: boolean,
+ onAdvancedRequest?: (data: MediaRequestBody) => void;
refetch: (options?: (RefetchOptions | undefined)) => Promise>;
-}> = ({ isLoading, result, details, refetch }) => {
+}> = ({
+ isLoading,
+ result,
+ details,
+ refetch,
+ hasAdvancedRequest,
+ onAdvancedRequest,
+}) => {
if (!details) return null;
const { jellyseerrApi, requestMedia } = useJellyseerr();
@@ -142,7 +153,7 @@ const JellyseerrSeasons: React.FC<{
const requestAll = useCallback(() => {
if (details && jellyseerrApi) {
- requestMedia(result?.name!!, {
+ const body: MediaRequestBody = {
mediaId: details.id,
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
@@ -151,19 +162,25 @@ const JellyseerrSeasons: React.FC<{
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
)
.map((s) => s.seasonNumber),
- });
+ }
+
+ if (hasAdvancedRequest) {
+ return onAdvancedRequest?.(body)
+ }
+
+ requestMedia(result?.name!!, body, refetch);
}
- }, [jellyseerrApi, seasons, details]);
+ }, [jellyseerrApi, seasons, details, hasAdvancedRequest, onAdvancedRequest]);
const promptRequestAll = useCallback(
() =>
- Alert.alert("Confirm", "Are you sure you want to request all seasons?", [
+ Alert.alert(t("jellyseerr.confirm"), t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"), [
{
- text: "Cancel",
+ text: t("jellyseerr.cancel"),
style: "cancel",
},
{
- text: "Yes",
+ text: t("jellyseerr.yes"),
onPress: requestAll,
},
]),
@@ -172,24 +189,26 @@ const JellyseerrSeasons: React.FC<{
const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => {
if (canRequest) {
- requestMedia(
- `${result?.name!!}, Season ${seasonNumber}`,
- {
- mediaId: details.id,
- mediaType: MediaType.TV,
- tvdbId: details.externalIds?.tvdbId,
- seasons: [seasonNumber],
- },
- refetch
- )
+ const body: MediaRequestBody = {
+ mediaId: details.id,
+ mediaType: MediaType.TV,
+ tvdbId: details.externalIds?.tvdbId,
+ seasons: [seasonNumber],
+ }
+
+ if (hasAdvancedRequest) {
+ return onAdvancedRequest?.(body)
+ }
+
+ requestMedia(`${result?.name!!}, Season ${seasonNumber}`, body, refetch);
}
- }, [requestMedia]);
+ }, [requestMedia, hasAdvancedRequest, onAdvancedRequest]);
if (isLoading)
return (
- Seasons
+ {t("item_card.seasons")}
{!allSeasonsAvailable && (
@@ -209,7 +228,7 @@ const JellyseerrSeasons: React.FC<{
)}
ListHeaderComponent={() => (
- Seasons
+ {t("item_card.seasons")}
{!allSeasonsAvailable && (
@@ -237,8 +256,8 @@ const JellyseerrSeasons: React.FC<{
{[0].map(() => {
@@ -246,7 +265,7 @@ const JellyseerrSeasons: React.FC<{
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
?.status === MediaStatus.UNKNOWN;
return (
- requestSeason(canRequest, season.seasonNumber)}
className={canRequest ? "bg-gray-700/40" : undefined}
diff --git a/components/series/NextUp.tsx b/components/series/NextUp.tsx
index 95834b9d..c76a61c6 100644
--- a/components/series/NextUp.tsx
+++ b/components/series/NextUp.tsx
@@ -12,10 +12,12 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { FlashList } from "@shopify/flash-list";
+import { useTranslation } from "react-i18next";
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
+ const { t } = useTranslation();
const { data: items } = useQuery({
queryKey: ["nextUp", seriesId],
@@ -37,14 +39,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
if (!items?.length)
return (
- Next up
- No items to display
+ {t("item_card.next_up")}
+ {t("item_card.no_items_to_display")}
);
return (
- Next up
+ {t("item_card.next_up")}
= ({
- Season {seasonIndex}
+ {t("item_card.season")} {seasonIndex}
@@ -104,7 +105,7 @@ export const SeasonDropdown: React.FC = ({
collisionPadding={8}
sideOffset={8}
>
- Seasons
+ {t("item_card.seasons")}
{seasons?.sort(sortByIndex).map((season: any) => (
= ({ item, initialSeasonIndex }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
+ const { t } = useTranslation();
const seasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""],
@@ -145,7 +146,7 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => {
/>
{episodes?.length || 0 > 0 ? (
(
@@ -210,7 +211,7 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => {
{(episodes?.length || 0) === 0 ? (
- No episodes for this season
+ {t("item_card.no_episodes_for_this_season")}
) : null}
diff --git a/components/series/SeriesActions.tsx b/components/series/SeriesActions.tsx
index 80d219f5..569f719d 100644
--- a/components/series/SeriesActions.tsx
+++ b/components/series/SeriesActions.tsx
@@ -1,24 +1,45 @@
+import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
+import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
-import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
-import { TouchableOpacity, View, ViewProps } from "react-native";
+import {
+ Alert,
+ Linking,
+ TouchableOpacity,
+ View,
+ ViewProps,
+} from "react-native";
interface Props extends ViewProps {
- item: BaseItemDto;
+ item: BaseItemDto | MovieDetails | TvDetails;
}
export const ItemActions = ({ item, ...props }: Props) => {
- const router = useRouter();
+ const trailerLink = useMemo(() => {
+ if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) {
+ return item.RemoteTrailers[0].Url;
+ }
- const trailerLink = useMemo(() => item.RemoteTrailers?.[0]?.Url, [item]);
+ if ("relatedVideos" in item) {
+ return item.relatedVideos?.find((v) => v.type === "Trailer")?.url;
+ }
+
+ return undefined;
+ }, [item]);
const openTrailer = useCallback(async () => {
- if (!trailerLink) return;
+ if (!trailerLink) {
+ Alert.alert("No trailer available");
+ return;
+ }
- const encodedTrailerLink = encodeURIComponent(trailerLink);
- router.push(`/trailer/page?url=${encodedTrailerLink}`);
- }, [router, trailerLink]);
+ try {
+ await Linking.openURL(trailerLink);
+ } catch (err) {
+ console.error("Failed to open trailer link:", err);
+ }
+ }, [trailerLink]);
return (
diff --git a/components/settings/AppLanguageSelector.tsx b/components/settings/AppLanguageSelector.tsx
new file mode 100644
index 00000000..5fdddba8
--- /dev/null
+++ b/components/settings/AppLanguageSelector.tsx
@@ -0,0 +1,76 @@
+import * as DropdownMenu from "zeego/dropdown-menu";
+import { TouchableOpacity, View, ViewProps } from "react-native";
+import { Text } from "../common/Text";
+import { useSettings } from "@/utils/atoms/settings";
+import { ListGroup } from "../list/ListGroup";
+import { ListItem } from "../list/ListItem";
+import { useTranslation } from "react-i18next";
+import { APP_LANGUAGES } from "@/i18n";
+
+interface Props extends ViewProps {}
+
+export const AppLanguageSelector: React.FC = ({ ...props }) => {
+ const [settings, updateSettings] = useSettings();
+ const { t } = useTranslation();
+
+ if (!settings) return null;
+
+ return (
+
+
+
+
+
+
+
+ {APP_LANGUAGES.find(
+ (l) => l.value === settings?.preferedLanguage
+ )?.label || t("home.settings.languages.system")}
+
+
+
+
+
+ {t("home.settings.languages.title")}
+
+ {
+ updateSettings({
+ preferedLanguage: undefined,
+ });
+ }}
+ >
+
+ {t("home.settings.languages.system")}
+
+
+ {APP_LANGUAGES?.map((l) => (
+ {
+ updateSettings({
+ preferedLanguage: l.value,
+ });
+ }}
+ >
+ {l.label}
+
+ ))}
+
+
+
+
+
+ );
+};
diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx
index 62aea437..44c1a2a8 100644
--- a/components/settings/AudioToggles.tsx
+++ b/components/settings/AudioToggles.tsx
@@ -3,43 +3,51 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { useMedia } from "./MediaContext";
import { Switch } from "react-native-gesture-handler";
+import { useTranslation } from "react-i18next";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons";
+import {useSettings} from "@/utils/atoms/settings";
interface Props extends ViewProps {}
export const AudioToggles: React.FC = ({ ...props }) => {
const media = useMedia();
+ const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media;
const cultures = media.cultures;
+ const { t } = useTranslation();
if (!settings) return null;
return (
- Choose a default audio language.
+ {t("home.settings.audio.audio_hint")}
}
>
-
+
updateSettings({ rememberAudioSelections: value })
}
/>
-
+
- {settings?.defaultAudioLanguage?.DisplayName || "None"}
+ {settings?.defaultAudioLanguage?.DisplayName || t("home.settings.audio.none")}
= ({ ...props }) => {
collisionPadding={8}
sideOffset={8}
>
- Languages
+ {t("home.settings.audio.language")}
{
@@ -66,7 +74,7 @@ export const AudioToggles: React.FC = ({ ...props }) => {
});
}}
>
- None
+ {t("home.settings.audio.none")}
{cultures?.map((l) => (
= ({
+ disabled = false,
+ showText = true,
+ text,
+ children,
+ ...props
+}) => (
+
+
+ {disabled && showText &&
+ {text ?? "Currently disabled by admin."}
+ }
+ {children}
+
+
+)
+
+export default DisabledSetting;
\ No newline at end of file
diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx
index f330dc04..8e233f38 100644
--- a/components/settings/DownloadSettings.tsx
+++ b/components/settings/DownloadSettings.tsx
@@ -1,35 +1,49 @@
import { Stepper } from "@/components/inputs/Stepper";
import { useDownload } from "@/providers/DownloadProvider";
-import { Settings, useSettings } from "@/utils/atoms/settings";
+import { DownloadMethod, Settings, useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
-import React from "react";
-import { Switch, TouchableOpacity, View } from "react-native";
+import React, { useMemo } from "react";
+import { Switch, TouchableOpacity } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
+import { useTranslation } from "react-i18next";
+import DisabledSetting from "@/components/settings/DisabledSetting";
export const DownloadSettings: React.FC = ({ ...props }) => {
- const [settings, updateSettings] = useSettings();
+ const [settings, updateSettings, pluginSettings] = useSettings();
const { setProcesses } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ const allDisabled = useMemo(
+ () =>
+ pluginSettings?.downloadMethod?.locked === true &&
+ pluginSettings?.remuxConcurrentLimit?.locked === true &&
+ pluginSettings?.autoDownload.locked === true,
+ [pluginSettings]
+ );
if (!settings) return null;
return (
-
-
-
+
+
+
- {settings.downloadMethod === "remux"
- ? "Default"
- : "Optimized"}
+ {settings.downloadMethod === DownloadMethod.Remux
+ ? t("home.settings.downloads.default")
+ : t("home.settings.downloads.optimized")}
{
collisionPadding={8}
sideOffset={8}
>
- Methods
+ {t("home.settings.downloads.methods")}
{
- updateSettings({ downloadMethod: "remux" });
+ updateSettings({ downloadMethod: DownloadMethod.Remux });
setProcesses([]);
}}
>
- Default
+ {t("home.settings.downloads.default")}
{
- updateSettings({ downloadMethod: "optimized" });
+ updateSettings({ downloadMethod: DownloadMethod.Optimized });
setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
- Optimized
+ {t("home.settings.downloads.optimized")}
{
updateSettings({ autoDownload: value })}
/>
router.push("/settings/optimized-server/page")}
showArrow
- title="Optimized Versions Server"
+ title={t("home.settings.downloads.optimized_versions_server")}
>
-
+
);
};
diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx
index 7dd41c73..5f0afdc5 100644
--- a/components/settings/Jellyseerr.tsx
+++ b/components/settings/Jellyseerr.tsx
@@ -1,17 +1,17 @@
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
-import { View } from "react-native";
-import { Text } from "../common/Text";
-import { useCallback, useRef, useState } from "react";
-import { Input } from "../common/Input";
-import { ListItem } from "../list/ListItem";
-import { Loader } from "../Loader";
+import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
-import { Button } from "../Button";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { useAtom } from "jotai";
-import { toast } from "sonner-native";
import { useMutation } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { useAtom } from "jotai";
+import { useState } from "react";
+import { View } from "react-native";
+import { toast } from "sonner-native";
+import { Button } from "../Button";
+import { Input } from "../common/Input";
+import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
+import { ListItem } from "../list/ListItem";
export const JellyseerrSettings = () => {
const {
@@ -21,8 +21,10 @@ export const JellyseerrSettings = () => {
clearAllJellyseerData,
} = useJellyseerr();
+ const { t } = useTranslation();
+
const [user] = useAtom(userAtom);
- const [settings, updateSettings] = useSettings();
+ const [settings, updateSettings, pluginSettings] = useSettings();
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
useState(false);
@@ -48,7 +50,7 @@ export const JellyseerrSettings = () => {
updateSettings({ jellyseerrServerUrl });
},
onError: () => {
- toast.error("Failed to login");
+ toast.error(t("jellyseerr.failed_to_login"));
},
onSettled: () => {
setJellyseerrPassword(undefined);
@@ -90,53 +92,50 @@ export const JellyseerrSettings = () => {
<>
>
) : (
- This integration is in its early stages. Expect things to change.
+ {t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
- Server URL
+ {t("home.settings.plugins.jellyseerr.server_url")}
- Example: http(s)://your-host.url
-
-
- (add port if required)
+ {t("home.settings.plugins.jellyseerr.server_url_hint")}
{
marginBottom: 8,
}}
>
- {promptForJellyseerrPass ? "Clear" : "Save"}
+ {promptForJellyseerrPass ? t("home.settings.plugins.jellyseerr.clear_button") : t("home.settings.plugins.jellyseerr.save_button")}
{
opacity: promptForJellyseerrPass ? 1 : 0.5,
}}
>
- Password
+ {t("home.settings.plugins.jellyseerr.password")}
{
className="h-12 mt-2"
onPress={() => loginToJellyseerrMutation.mutate()}
>
- Login
+ {t("home.settings.plugins.jellyseerr.login_button")}
diff --git a/components/settings/MediaToggles.tsx b/components/settings/MediaToggles.tsx
index 7e4c4346..ae431ffb 100644
--- a/components/settings/MediaToggles.tsx
+++ b/components/settings/MediaToggles.tsx
@@ -1,72 +1,64 @@
-import React from "react";
-import { TouchableOpacity, View, ViewProps } from "react-native";
+import React, {useMemo} from "react";
+import { ViewProps } from "react-native";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
-import { Text } from "../common/Text";
+import { useTranslation } from "react-i18next";
+import DisabledSetting from "@/components/settings/DisabledSetting";
+import {Stepper} from "@/components/inputs/Stepper";
interface Props extends ViewProps {}
export const MediaToggles: React.FC = ({ ...props }) => {
- const [settings, updateSettings] = useSettings();
+ const { t } = useTranslation();
+
+ const [settings, updateSettings, pluginSettings] = useSettings();
if (!settings) return null;
- const renderSkipControl = (
- value: number,
- onDecrease: () => void,
- onIncrease: () => void
- ) => (
-
-
- -
-
-
- {value}s
-
-
- +
-
-
- );
+ const disabled = useMemo(() => (
+ pluginSettings?.forwardSkipTime?.locked === true &&
+ pluginSettings?.rewindSkipTime?.locked === true
+ ),
+ [pluginSettings]
+ )
return (
-
-
-
- {renderSkipControl(
- settings.forwardSkipTime,
- () =>
- updateSettings({
- forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
- }),
- () =>
- updateSettings({
- forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
- })
- )}
+
+
+
+ updateSettings({forwardSkipTime})}
+ />
-
- {renderSkipControl(
- settings.rewindSkipTime,
- () =>
- updateSettings({
- rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
- }),
- () =>
- updateSettings({
- rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
- })
- )}
+
+ updateSettings({rewindSkipTime})}
+ />
-
+
);
};
diff --git a/components/settings/OptimizedServerForm.tsx b/components/settings/OptimizedServerForm.tsx
index 2aa7ebda..35910f04 100644
--- a/components/settings/OptimizedServerForm.tsx
+++ b/components/settings/OptimizedServerForm.tsx
@@ -1,5 +1,6 @@
import { TextInput, View, Linking } from "react-native";
import { Text } from "../common/Text";
+import { useTranslation } from "react-i18next";
interface Props {
value: string;
@@ -14,14 +15,16 @@ export const OptimizedServerForm: React.FC = ({
Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
};
+ const { t } = useTranslation();
+
return (
- URL
+ {t("home.settings.downloads.url")}
= ({
- Enter the URL for the optimize server. The URL should include http or
- https and optionally the port.{" "}
+ {t("home.settings.downloads.optimized_version_hint")}{" "}
- Read more about the optimize server.
+ {t("home.settings.downloads.read_more_about_optimized_server")}
diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx
index d280a167..8ddbca48 100644
--- a/components/settings/OtherSettings.tsx
+++ b/components/settings/OtherSettings.tsx
@@ -6,20 +6,24 @@ import {
} from "@/utils/background-tasks";
import { Ionicons } from "@expo/vector-icons";
import * as BackgroundFetch from "expo-background-fetch";
+import { useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
-import React, { useEffect } from "react";
-import { Linking, Switch, TouchableOpacity, ViewProps } from "react-native";
+import React, { useEffect, useMemo } from "react";
+import { Linking, Switch, TouchableOpacity } from "react-native";
import { toast } from "sonner-native";
-import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
-
-interface Props extends ViewProps {}
+import { useTranslation } from "react-i18next";
+import DisabledSetting from "@/components/settings/DisabledSetting";
+import Dropdown from "@/components/common/Dropdown";
export const OtherSettings: React.FC = () => {
- const [settings, updateSettings] = useSettings();
+ const router = useRouter();
+ const [settings, updateSettings, pluginSettings] = useSettings();
+
+ const { t } = useTranslation();
/********************
* Background task
@@ -51,133 +55,122 @@ export const OtherSettings: React.FC = () => {
/**********************
*********************/
+ const disabled = useMemo(
+ () =>
+ pluginSettings?.autoRotate?.locked === true &&
+ pluginSettings?.defaultVideoOrientation?.locked === true &&
+ pluginSettings?.safeAreaInControlsEnabled?.locked === true &&
+ pluginSettings?.showCustomMenuLinks?.locked === true &&
+ pluginSettings?.hiddenLibraries?.locked === true &&
+ pluginSettings?.disableHapticFeedback?.locked === true,
+ [pluginSettings]
+ );
+
+ const orientations = [
+ ScreenOrientation.OrientationLock.DEFAULT,
+ ScreenOrientation.OrientationLock.PORTRAIT_UP,
+ ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
+ ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
+ ];
+
if (!settings) return null;
return (
-
-
- updateSettings({ autoRotate: value })}
- />
-
+
+
+
+ updateSettings({ autoRotate: value })}
+ />
+
-
-
-
-
-
- {ScreenOrientationEnum[settings.defaultVideoOrientation]}
-
-
-
-
-
- Orientation
- {
- updateSettings({
- defaultVideoOrientation:
- ScreenOrientation.OrientationLock.DEFAULT,
- });
- }}
- >
-
- {
- ScreenOrientationEnum[
- ScreenOrientation.OrientationLock.DEFAULT
- ]
- }
-
-
- {
- updateSettings({
- defaultVideoOrientation:
- ScreenOrientation.OrientationLock.PORTRAIT_UP,
- });
- }}
- >
-
- {
- ScreenOrientationEnum[
- ScreenOrientation.OrientationLock.PORTRAIT_UP
- ]
- }
-
-
- {
- updateSettings({
- defaultVideoOrientation:
- ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
- });
- }}
- >
-
- {
- ScreenOrientationEnum[
- ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
- ]
- }
-
-
- {
- updateSettings({
- defaultVideoOrientation:
- ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
- });
- }}
- >
-
- {
- ScreenOrientationEnum[
- ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
- ]
- }
-
-
-
-
-
-
-
-
- updateSettings({ safeAreaInControlsEnabled: value })
+
-
+ >
+ ScreenOrientationEnum[item]}
+ title={
+
+
+ {t(ScreenOrientationEnum[settings.defaultVideoOrientation])}
+
+
+
+ }
+ label={t("home.settings.other.orientation")}
+ onSelected={(defaultVideoOrientation) =>
+ updateSettings({ defaultVideoOrientation })
+ }
+ />
+
-
- Linking.openURL(
- "https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
- )
- }
- >
-
- updateSettings({ showCustomMenuLinks: value })
+
+
+ updateSettings({ safeAreaInControlsEnabled: value })
+ }
+ />
+
+
+
+ Linking.openURL(
+ "https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
+ )
}
+ >
+
+ updateSettings({ showCustomMenuLinks: value })
+ }
+ />
+
+ router.push("/settings/hide-libraries/page")}
+ title={t("home.settings.other.hide_libraries")}
+ showArrow
/>
-
-
+
+
+ updateSettings({ disableHapticFeedback })
+ }
+ />
+
+
+
);
};
diff --git a/components/settings/PluginSettings.tsx b/components/settings/PluginSettings.tsx
index f611d1a6..caac1e33 100644
--- a/components/settings/PluginSettings.tsx
+++ b/components/settings/PluginSettings.tsx
@@ -4,16 +4,19 @@ import React from "react";
import { View } from "react-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
+import { useTranslation } from "react-i18next";
export const PluginSettings = () => {
const [settings, updateSettings] = useSettings();
const router = useRouter();
+ const { t } = useTranslation();
+
if (!settings) return null;
return (
-
+
router.push("/settings/jellyseerr/page")}
title={"Jellyseerr"}
@@ -24,11 +27,6 @@ export const PluginSettings = () => {
title="Marlin Search"
showArrow
/>
- router.push("/settings/popular-lists/page")}
- title="Popular Lists"
- showArrow
- />
);
diff --git a/components/settings/QuickConnect.tsx b/components/settings/QuickConnect.tsx
index 226120bb..c3559b62 100644
--- a/components/settings/QuickConnect.tsx
+++ b/components/settings/QuickConnect.tsx
@@ -1,59 +1,118 @@
-import { Alert, View, ViewProps } from "react-native";
-import { Text } from "../common/Text";
-import { ListItem } from "../list/ListItem";
-import { Button } from "../Button";
-import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
-import { useAtom } from "jotai";
-import Constants from "expo-constants";
-import Application from "expo-application";
-import { ListGroup } from "../list/ListGroup";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import {
+ BottomSheetBackdrop,
+ BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetTextInput,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
-import * as Haptics from "expo-haptics";
+import { useTranslation } from "react-i18next";
+import { useHaptic } from "@/hooks/useHaptic";
+import { useAtom } from "jotai";
+import React, { useCallback, useRef, useState } from "react";
+import { Alert, View, ViewProps } from "react-native";
+import { Button } from "../Button";
+import { Text } from "../common/Text";
+import { ListGroup } from "../list/ListGroup";
+import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {}
export const QuickConnect: React.FC = ({ ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
+ const [quickConnectCode, setQuickConnectCode] = useState();
+ const bottomSheetModalRef = useRef(null);
+ const successHapticFeedback = useHaptic("success");
+ const errorHapticFeedback = useHaptic("error");
- const openQuickConnectAuthCodeInput = () => {
- Alert.prompt(
- "Quick connect",
- "Enter the quick connect code",
- async (text) => {
- if (text) {
- try {
- const res = await getQuickConnectApi(api!).authorizeQuickConnect({
- code: text,
- userId: user?.Id,
- });
- if (res.status === 200) {
- Haptics.notificationAsync(
- Haptics.NotificationFeedbackType.Success
- );
- Alert.alert("Success", "Quick connect authorized");
- } else {
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
- Alert.alert("Error", "Invalid code");
- }
- } catch (e) {
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
- Alert.alert("Error", "Invalid code");
- }
+ const { t } = useTranslation();
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ []
+ );
+
+ const authorizeQuickConnect = useCallback(async () => {
+ if (quickConnectCode) {
+ try {
+ const res = await getQuickConnectApi(api!).authorizeQuickConnect({
+ code: quickConnectCode,
+ userId: user?.Id,
+ });
+ if (res.status === 200) {
+ successHapticFeedback();
+ Alert.alert(t("home.settings.quick_connect.success"), t("home.settings.quick_connect.quick_connect_autorized"));
+ setQuickConnectCode(undefined);
+ bottomSheetModalRef?.current?.close();
+ } else {
+ errorHapticFeedback();
+ Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
}
+ } catch (e) {
+ errorHapticFeedback();
+ Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
}
- );
- };
+ }
+ }, [api, user, quickConnectCode]);
return (
-
+
bottomSheetModalRef?.current?.present()}
+ title={t("home.settings.quick_connect.authorize_button")}
textColor="blue"
- >
+ />
+
+
+
+
+
+
+ {t("home.settings.quick_connect.quick_connect_title")}
+
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx
index 5b693acd..6b9c8444 100644
--- a/components/settings/StorageSettings.tsx
+++ b/components/settings/StorageSettings.tsx
@@ -1,18 +1,19 @@
-import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
+import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider";
-import { clearLogs } from "@/utils/log";
import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
-import * as Haptics from "expo-haptics";
import { View } from "react-native";
-import * as Progress from "react-native-progress";
import { toast } from "sonner-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
+import { useTranslation } from "react-i18next";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
+ const { t } = useTranslation();
+ const successHapticFeedback = useHaptic("success");
+ const errorHapticFeedback = useHaptic("error");
const { data: size, isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize", appSizeUsage],
@@ -29,10 +30,10 @@ export const StorageSettings = () => {
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ successHapticFeedback();
} catch (e) {
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
- toast.error("Error deleting files");
+ errorHapticFeedback();
+ toast.error(t("home.settings.toasts.error_deleting_files"));
}
};
@@ -44,11 +45,10 @@ export const StorageSettings = () => {
- Storage
+ {t("home.settings.storage.storage_title")}
{size && (
- {Number(size.total - size.remaining).bytesToReadable()} of{" "}
- {size.total?.bytesToReadable()} used
+ {t("home.settings.storage.size_used", {used: Number(size.total - size.remaining).bytesToReadable(), total: size.total?.bytesToReadable()})}
)}
@@ -79,18 +79,13 @@ export const StorageSettings = () => {
- App {calculatePercentage(size.app, size.total)}%
+ {t("home.settings.storage.app_usage", {usedSpace: calculatePercentage(size.app, size.total)})}
- Phone{" "}
- {calculatePercentage(
- size.total - size.remaining - size.app,
- size.total
- )}
- %
+ {t("home.settings.storage.phone_usage", {availableSpace: calculatePercentage(size.total - size.remaining - size.app, size.total)})}
>
@@ -101,7 +96,7 @@ export const StorageSettings = () => {
diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx
index 66c514b1..3748d0e5 100644
--- a/components/settings/SubtitleToggles.tsx
+++ b/components/settings/SubtitleToggles.tsx
@@ -7,13 +7,19 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
+import { useTranslation } from "react-i18next";
+import {useSettings} from "@/utils/atoms/settings";
+import {Stepper} from "@/components/inputs/Stepper";
+import Dropdown from "@/components/common/Dropdown";
interface Props extends ViewProps {}
export const SubtitleToggles: React.FC = ({ ...props }) => {
const media = useMedia();
+ const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media;
const cultures = media.cultures;
+ const { t } = useTranslation();
if (!settings) return null;
@@ -25,22 +31,33 @@ export const SubtitleToggles: React.FC = ({ ...props }) => {
SubtitlePlaybackMode.None,
];
+ const subtitleModeKeys = {
+ [SubtitlePlaybackMode.Default]: "home.settings.subtitles.modes.Default",
+ [SubtitlePlaybackMode.Smart]: "home.settings.subtitles.modes.Smart",
+ [SubtitlePlaybackMode.OnlyForced]: "home.settings.subtitles.modes.OnlyForced",
+ [SubtitlePlaybackMode.Always]: "home.settings.subtitles.modes.Always",
+ [SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None",
+ };
+
return (
- Configure subtitle preferences.
+ {t("home.settings.subtitles.subtitle_hint")}
}
>
-
-
-
+
+ item?.ThreeLetterISOLanguageName ?? "unknown"}
+ titleExtractor={(item) => item?.DisplayName}
+ title={
- {settings?.defaultSubtitleLanguage?.DisplayName || "None"}
+ {settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")}
= ({ ...props }) => {
color="#5A5960"
/>
-
-
- Languages
- {
- updateSettings({
- defaultSubtitleLanguage: null,
- });
- }}
- >
- None
-
- {cultures?.map((l) => (
- {
- updateSettings({
- defaultSubtitleLanguage: l,
- });
- }}
- >
-
- {l.DisplayName}
-
-
- ))}
-
-
+ }
+ label={t("home.settings.subtitles.language")}
+ onSelected={(defaultSubtitleLanguage) =>
+ updateSettings({
+ defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === t("home.settings.subtitles.none")
+ ? null
+ : defaultSubtitleLanguage
+ })
+ }
+ />
-
-
-
+
+ t(subtitleModeKeys[item]) || String(item)}
+ title={
- {settings?.subtitleMode || "Loading"}
+ {t(subtitleModeKeys[settings?.subtitleMode]) || t("home.settings.subtitles.loading")}
= ({ ...props }) => {
color="#5A5960"
/>
-
-
- Subtitle Mode
- {subtitleModes?.map((l) => (
- {
- updateSettings({
- subtitleMode: l,
- });
- }}
- >
- {l}
-
- ))}
-
-
+ }
+ label={t("home.settings.subtitles.subtitle_mode")}
+ onSelected={(subtitleMode) =>
+ updateSettings({subtitleMode})
+ }
+ />
-
+
updateSettings({ rememberSubtitleSelections: value })
}
/>
-
-
-
- updateSettings({
- subtitleSize: Math.max(0, settings.subtitleSize - 5),
- })
- }
- className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
- >
- -
-
-
- {settings.subtitleSize}
-
-
- updateSettings({
- subtitleSize: Math.min(120, settings.subtitleSize + 5),
- })
- }
- >
- +
-
-
+
+ updateSettings({subtitleSize})}
+ />
diff --git a/components/settings/UserInfo.tsx b/components/settings/UserInfo.tsx
index c42502ce..fdd3db44 100644
--- a/components/settings/UserInfo.tsx
+++ b/components/settings/UserInfo.tsx
@@ -7,12 +7,14 @@ import { useAtom } from "jotai";
import Constants from "expo-constants";
import Application from "expo-application";
import { ListGroup } from "../list/ListGroup";
+import { useTranslation } from "react-i18next";
interface Props extends ViewProps {}
export const UserInfo: React.FC = ({ ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
+ const { t } = useTranslation();
const version =
Application?.nativeApplicationVersion ||
@@ -21,11 +23,11 @@ export const UserInfo: React.FC = ({ ...props }) => {
return (
-
-
-
-
-
+
+
+
+
+
);
diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx
index 024b1272..2cfeed1d 100644
--- a/components/stacks/NestedTabPageStack.tsx
+++ b/components/stacks/NestedTabPageStack.tsx
@@ -17,14 +17,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerLeft: () => ,
};
-const routes = [
- "actors/[actorId]",
- "albums/[albumId]",
- "artists/index",
- "artists/[artistId]",
- "items/page",
- "series/[id]",
-];
+const routes = ["actors/[actorId]", "items/page", "series/[id]"];
export const nestedTabPageScreenOptions: Record =
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx
index d7386134..f209a411 100644
--- a/components/video-player/controls/Controls.tsx
+++ b/components/video-player/controls/Controls.tsx
@@ -1,8 +1,8 @@
-import React, { useCallback, useEffect, useRef, useState } from "react";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
+import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import {
@@ -29,12 +29,13 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
-import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router";
+import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import { debounce } from "lodash";
-import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { TouchableOpacity, useWindowDimensions, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
@@ -42,10 +43,7 @@ import {
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
-import {
- SafeAreaView,
- useSafeAreaInsets,
-} from "react-native-safe-area-context";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import { VideoRef } from "react-native-video";
import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
@@ -56,6 +54,8 @@ import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
+import { useControlsTimeout } from "./useControlsTimeout";
+import { VideoTouchOverlay } from "./VideoTouchOverlay";
interface Props {
item: BaseItemDto;
@@ -86,6 +86,8 @@ interface Props {
isVlc?: boolean;
}
+const CONTROLS_TIMEOUT = 4000;
+
export const Controls: React.FC = ({
item,
seek,
@@ -118,6 +120,13 @@ export const Controls: React.FC = ({
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
+ const [episodeView, setEpisodeView] = useState(false);
+ const [isSliding, setIsSliding] = useState(false);
+
+ // Used when user changes audio through audio button on device.
+ const [showAudioSlider, setShowAudioSlider] = useState(false);
+
+ const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const { previousItem, nextItem } = useAdjacentItems({ item });
const {
trickPlayUrl,
@@ -135,6 +144,23 @@ export const Controls: React.FC = ({
const wasPlayingRef = useRef(false);
const lastProgressRef = useRef(0);
+ const lightHapticFeedback = useHaptic("light");
+
+ useEffect(() => {
+ prefetchAllTrickplayImages();
+ }, []);
+
+ useEffect(() => {
+ if (item) {
+ progress.value = isVlc
+ ? ticksToMs(item?.UserData?.PlaybackPositionTicks)
+ : item?.UserData?.PlaybackPositionTicks || 0;
+ max.value = isVlc
+ ? ticksToMs(item.RunTimeTicks || 0)
+ : item.RunTimeTicks || 0;
+ }
+ }, [item, isVlc]);
+
const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
bitrateValue: string;
audioIndex: string;
@@ -160,7 +186,7 @@ export const Controls: React.FC = ({
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
@@ -198,7 +224,7 @@ export const Controls: React.FC = ({
const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return;
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
@@ -260,20 +286,19 @@ export const Controls: React.FC = ({
[updateTimes]
);
- useEffect(() => {
- if (item) {
- progress.value = isVlc
- ? ticksToMs(item?.UserData?.PlaybackPositionTicks)
- : item?.UserData?.PlaybackPositionTicks || 0;
- max.value = isVlc
- ? ticksToMs(item.RunTimeTicks || 0)
- : item.RunTimeTicks || 0;
- }
- }, [item, isVlc]);
-
- useEffect(() => {
- prefetchAllTrickplayImages();
+ const hideControls = useCallback(() => {
+ setShowControls(false);
+ setShowAudioSlider(false);
}, []);
+
+ const { handleControlsInteraction } = useControlsTimeout({
+ showControls,
+ isSliding,
+ episodeView,
+ onHideControls: hideControls,
+ timeout: CONTROLS_TIMEOUT,
+ });
+
const toggleControls = () => {
if (showControls) {
setShowAudioSlider(false);
@@ -294,16 +319,13 @@ export const Controls: React.FC = ({
isSeeking.value = true;
}, [showControls, isPlaying]);
- const [isSliding, setIsSliding] = useState(false);
const handleSliderComplete = useCallback(
async (value: number) => {
isSeeking.value = false;
progress.value = value;
setIsSliding(false);
- await seek(
- Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
- );
+ seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
if (wasPlayingRef.current === true) play();
},
[isVlc]
@@ -326,14 +348,14 @@ export const Controls: React.FC = ({
const handleSkipBackward = useCallback(async () => {
if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying;
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
- await seek(newTime);
+ seek(newTime);
if (wasPlayingRef.current === true) play();
}
} catch (error) {
@@ -344,14 +366,14 @@ export const Controls: React.FC = ({
const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying;
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(settings.forwardSkipTime)
: ticksToSeconds(curr) + settings.forwardSkipTime;
- await seek(Math.max(0, newTime));
+ seek(Math.max(0, newTime));
if (wasPlayingRef.current === true) play();
}
} catch (error) {
@@ -359,11 +381,62 @@ export const Controls: React.FC = ({
}
}, [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(() => {
setIgnoreSafeAreas((prev) => !prev);
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
}, []);
+ const switchOnEpisodeMode = useCallback(() => {
+ setEpisodeView(true);
+ if (isPlaying) togglePlay();
+ }, [isPlaying, togglePlay]);
+
const memoizedRenderBubble = useCallback(() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
@@ -376,12 +449,11 @@ export const Controls: React.FC = ({
= ({
);
}, [trickPlayUrl, trickplayInfo, time]);
- const [EpisodeView, setEpisodeView] = useState(false);
-
- const switchOnEpisodeMode = () => {
- setEpisodeView(true);
- if (isPlaying) togglePlay();
- };
-
- const goToItem = useCallback(
- async (itemId: string) => {
- try {
- const gotoItem = await getItemById(api, itemId);
- if (!settings || !gotoItem) return;
-
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
-
- 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]
- );
-
- // Used when user changes audio through audio button on device.
- const [showAudioSlider, setShowAudioSlider] = useState(false);
-
return (
- {EpisodeView ? (
+ {episodeView ? (
setEpisodeView(false)}
@@ -497,88 +513,76 @@ export const Controls: React.FC = ({
/>
) : (
<>
-
-
- {!mediaSource?.TranscodingUrl ? (
-
- ) : (
-
- )}
-
-
-
- {
- toggleControls();
- }}
- style={{
- position: "absolute",
- width: Dimensions.get("window").width,
- height: Dimensions.get("window").height,
- }}
- >
-
+
- {item?.Type === "Episode" && !offline && (
- {
- switchOnEpisodeMode();
- }}
- className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
+
+
-
-
- )}
- {previousItem && !offline && (
-
-
-
- )}
+ {!mediaSource?.TranscodingUrl ? (
+
+ ) : (
+
+ )}
+
+
- {nextItem && !offline && (
-
-
-
- )}
+
+ {item?.Type === "Episode" && !offline && (
+ {
+ switchOnEpisodeMode();
+ }}
+ className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
+ >
+
+
+ )}
+ {previousItem && !offline && (
+
+
+
+ )}
- {mediaSource?.TranscodingUrl && (
+ {nextItem && !offline && (
+
+
+
+ )}
+
+ {/* {mediaSource?.TranscodingUrl && ( */}
= ({
color="white"
/>
- )}
- {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
- router.back();
- }}
- className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
- >
-
-
+ {/* )} */}
+ {
+ lightHapticFeedback();
+ await ScreenOrientation.lockAsync(
+ ScreenOrientation.OrientationLock.PORTRAIT_UP
+ );
+ router.back();
+ }}
+ className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
+ >
+
+
+
= ({
bottom: settings?.safeAreaInControlsEnabled ? insets.bottom : 0,
},
]}
- className={`flex flex-col p-4`}
+ className={`flex flex-col px-2`}
+ onTouchStart={handleControlsInteraction}
>
= ({
}}
pointerEvents={showControls ? "box-none" : "none"}
>
- {item?.Name}
{item?.Type === "Episode" && (
- {item.SeriesName}
+
+ {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}
+
)}
+ {item?.Name}
{item?.Type === "Movie" && (
{item?.ProductionYear}
@@ -776,7 +787,7 @@ export const Controls: React.FC = ({
= ({
bubbleTextColor: "#666",
heartbeatColor: "#999",
}}
- renderThumb={() => (
-
- )}
+ renderThumb={() => null}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
@@ -819,7 +818,7 @@ export const Controls: React.FC = ({
minimumValue={min}
maximumValue={max}
/>
-
+
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx
index db7c5c71..422a2fc3 100644
--- a/components/video-player/controls/EpisodeList.tsx
+++ b/components/video-player/controls/EpisodeList.tsx
@@ -1,26 +1,26 @@
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { runtimeTicksToSeconds } from "@/utils/time";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { atom, useAtom } from "jotai";
-import { useEffect, useMemo, useState, useRef } from "react";
-import { View, TouchableOpacity } from "react-native";
-import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
-import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
-import { Ionicons } from "@expo/vector-icons";
-import { Loader } from "@/components/Loader";
-import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
-import { Text } from "@/components/common/Text";
-import { DownloadSingleItem } from "@/components/DownloadItem";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
HorizontalScroll,
HorizontalScrollRef,
} from "@/components/common/HorrizontalScroll";
+import { Text } from "@/components/common/Text";
+import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
+import { DownloadSingleItem } from "@/components/DownloadItem";
+import { Loader } from "@/components/Loader";
import {
SeasonDropdown,
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
+import { runtimeTicksToSeconds } from "@/utils/time";
+import { Ionicons } from "@expo/vector-icons";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { atom, useAtom } from "jotai";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
type Props = {
item: BaseItemDto;
diff --git a/components/video-player/controls/NextEpisodeCountDownButton.tsx b/components/video-player/controls/NextEpisodeCountDownButton.tsx
index 6f5239e6..e77c6198 100644
--- a/components/video-player/controls/NextEpisodeCountDownButton.tsx
+++ b/components/video-player/controls/NextEpisodeCountDownButton.tsx
@@ -9,6 +9,7 @@ import Animated, {
runOnJS,
} from "react-native-reanimated";
import { Colors } from "@/constants/Colors";
+import { useTranslation } from "react-i18next";
interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps {
onFinish?: () => void;
@@ -63,6 +64,8 @@ const NextEpisodeCountDownButton: React.FC = ({
return null;
}
+ const { t } = useTranslation();
+
return (
= ({
>
- Next Episode
+ {t("player.next_episode")}
);
diff --git a/components/video-player/controls/VideoTouchOverlay.tsx b/components/video-player/controls/VideoTouchOverlay.tsx
new file mode 100644
index 00000000..85385acf
--- /dev/null
+++ b/components/video-player/controls/VideoTouchOverlay.tsx
@@ -0,0 +1,38 @@
+import { Pressable } from "react-native";
+import { useTapDetection } from "./useTapDetection";
+
+interface Props {
+ screenWidth: number;
+ screenHeight: number;
+ showControls: boolean;
+ onToggleControls: () => void;
+}
+
+export const VideoTouchOverlay = ({
+ screenWidth,
+ screenHeight,
+ showControls,
+ onToggleControls,
+}: Props) => {
+ const { handleTouchStart, handleTouchEnd } = useTapDetection({
+ onValidTap: onToggleControls,
+ });
+
+ return (
+
+ );
+};
diff --git a/components/video-player/controls/dropdown/DropdownViewDirect.tsx b/components/video-player/controls/dropdown/DropdownViewDirect.tsx
index 28b55fa0..e2ba25fd 100644
--- a/components/video-player/controls/dropdown/DropdownViewDirect.tsx
+++ b/components/video-player/controls/dropdown/DropdownViewDirect.tsx
@@ -74,7 +74,7 @@ const DropdownViewDirect: React.FC = ({
return (
-
+
diff --git a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx
index 8739b07a..5a05dd17 100644
--- a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx
+++ b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx
@@ -118,17 +118,10 @@ const DropdownView: React.FC = ({ showControls }) => {
);
return (
-
+
-
+
diff --git a/components/video-player/controls/useControlsTimeout.ts b/components/video-player/controls/useControlsTimeout.ts
new file mode 100644
index 00000000..ac10fff3
--- /dev/null
+++ b/components/video-player/controls/useControlsTimeout.ts
@@ -0,0 +1,56 @@
+import { useEffect, useRef } from "react";
+
+interface UseControlsTimeoutProps {
+ showControls: boolean;
+ isSliding: boolean;
+ episodeView: boolean;
+ onHideControls: () => void;
+ timeout?: number;
+}
+
+export const useControlsTimeout = ({
+ showControls,
+ isSliding,
+ episodeView,
+ onHideControls,
+ timeout = 4000,
+}: UseControlsTimeoutProps) => {
+ const controlsTimeoutRef = useRef();
+
+ useEffect(() => {
+ const resetControlsTimeout = () => {
+ if (controlsTimeoutRef.current) {
+ clearTimeout(controlsTimeoutRef.current);
+ }
+
+ if (showControls && !isSliding && !episodeView) {
+ controlsTimeoutRef.current = setTimeout(() => {
+ onHideControls();
+ }, timeout);
+ }
+ };
+
+ resetControlsTimeout();
+
+ return () => {
+ if (controlsTimeoutRef.current) {
+ clearTimeout(controlsTimeoutRef.current);
+ }
+ };
+ }, [showControls, isSliding, episodeView, timeout, onHideControls]);
+
+ const handleControlsInteraction = () => {
+ if (showControls) {
+ if (controlsTimeoutRef.current) {
+ clearTimeout(controlsTimeoutRef.current);
+ }
+ controlsTimeoutRef.current = setTimeout(() => {
+ onHideControls();
+ }, timeout);
+ }
+ };
+
+ return {
+ handleControlsInteraction,
+ };
+};
diff --git a/components/video-player/controls/useTapDetection.tsx b/components/video-player/controls/useTapDetection.tsx
new file mode 100644
index 00000000..041e6d39
--- /dev/null
+++ b/components/video-player/controls/useTapDetection.tsx
@@ -0,0 +1,48 @@
+import { useRef } from "react";
+import { GestureResponderEvent } from "react-native";
+
+interface TapDetectionOptions {
+ maxDuration?: number;
+ maxDistance?: number;
+ onValidTap?: () => void;
+}
+
+export const useTapDetection = ({
+ maxDuration = 200,
+ maxDistance = 10,
+ onValidTap,
+}: TapDetectionOptions = {}) => {
+ const touchStartTime = useRef(0);
+ const touchStartPosition = useRef({ x: 0, y: 0 });
+
+ const handleTouchStart = (event: GestureResponderEvent) => {
+ touchStartTime.current = Date.now();
+ touchStartPosition.current = {
+ x: event.nativeEvent.pageX,
+ y: event.nativeEvent.pageY,
+ };
+ };
+
+ const handleTouchEnd = (event: GestureResponderEvent) => {
+ const touchEndTime = Date.now();
+ const touchEndPosition = {
+ x: event.nativeEvent.pageX,
+ y: event.nativeEvent.pageY,
+ };
+
+ const touchDuration = touchEndTime - touchStartTime.current;
+ const touchDistance = Math.sqrt(
+ Math.pow(touchEndPosition.x - touchStartPosition.current.x, 2) +
+ Math.pow(touchEndPosition.y - touchStartPosition.current.y, 2)
+ );
+
+ if (touchDuration < maxDuration && touchDistance < maxDistance) {
+ onValidTap?.();
+ }
+ };
+
+ return {
+ handleTouchStart,
+ handleTouchEnd,
+ };
+};
diff --git a/components/vlc/VideoDebugInfo.tsx b/components/vlc/VideoDebugInfo.tsx
index 5ae04517..8a37659a 100644
--- a/components/vlc/VideoDebugInfo.tsx
+++ b/components/vlc/VideoDebugInfo.tsx
@@ -6,6 +6,7 @@ import React, { useEffect, useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "../common/Text";
+import { useTranslation } from "react-i18next";
interface Props extends ViewProps {
playerRef: React.RefObject;
@@ -32,6 +33,8 @@ export const VideoDebugInfo: React.FC = ({ playerRef, ...props }) => {
const insets = useSafeAreaInsets();
+ const { t } = useTranslation();
+
return (
= ({ playerRef, ...props }) => {
}}
{...props}
>
- Playback State:
- Audio Tracks:
+ {t("player.playback_state")}
+ {t("player.audio_tracks")}
{audioTracks &&
audioTracks.map((track, index) => (
- {track.name} (Index: {track.index})
+ {track.name} ({t("player.index")} {track.index})
))}
- Subtitle Tracks:
+ {t("player.subtitles_tracks")}
{subtitleTracks &&
subtitleTracks.map((track, index) => (
- {track.name} (Index: {track.index})
+ {track.name} ({t("player.index")} {track.index})
))}
= ({ playerRef, ...props }) => {
}
}}
>
- Refresh Tracks
+ {t("player.refresh_tracks")}
);
diff --git a/eas.json b/eas.json
index af2b7e82..9821cceb 100644
--- a/eas.json
+++ b/eas.json
@@ -22,13 +22,13 @@
}
},
"production": {
- "channel": "0.23.0",
+ "channel": "0.25.0",
"android": {
"image": "latest"
}
},
"production-apk": {
- "channel": "0.23.0",
+ "channel": "0.25.0",
"android": {
"buildType": "apk",
"image": "latest"
diff --git a/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts
index 1430e7c9..14a77161 100644
--- a/hooks/useCreditSkipper.ts
+++ b/hooks/useCreditSkipper.ts
@@ -5,7 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import { msToSeconds, secondsToMs } from "@/utils/time";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "./useHaptic";
interface CreditTimestamps {
Introduction: {
@@ -29,6 +29,7 @@ export const useCreditSkipper = (
) => {
const [api] = useAtom(apiAtom);
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
+ const lightHapticFeedback = useHaptic("light");
if (isVlc) {
currentTime = msToSeconds(currentTime);
@@ -79,7 +80,7 @@ export const useCreditSkipper = (
if (!creditTimestamps) return;
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
try {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
wrappedSeek(creditTimestamps.Credits.End);
setTimeout(() => {
play();
diff --git a/hooks/useDefaultPlaySettings.ts b/hooks/useDefaultPlaySettings.ts
index 5c2d9cc6..39009305 100644
--- a/hooks/useDefaultPlaySettings.ts
+++ b/hooks/useDefaultPlaySettings.ts
@@ -6,7 +6,7 @@ import {
} from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
-// Used only for intial play settings.
+// Used only for initial play settings.
const useDefaultPlaySettings = (
item: BaseItemDto,
settings: Settings | null
diff --git a/hooks/useHaptic.ts b/hooks/useHaptic.ts
new file mode 100644
index 00000000..c992def1
--- /dev/null
+++ b/hooks/useHaptic.ts
@@ -0,0 +1,54 @@
+import { useCallback, useMemo } from "react";
+import { Platform } from "react-native";
+import * as Haptics from "expo-haptics";
+import { useSettings } from "@/utils/atoms/settings";
+
+export type HapticFeedbackType =
+ | "light"
+ | "medium"
+ | "heavy"
+ | "selection"
+ | "success"
+ | "warning"
+ | "error";
+
+export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
+ const [settings] = useSettings();
+
+ const createHapticHandler = useCallback(
+ (type: Haptics.ImpactFeedbackStyle) => {
+ return Platform.OS === "web" ? () => {} : () => Haptics.impactAsync(type);
+ },
+ []
+ );
+ const createNotificationFeedback = useCallback(
+ (type: Haptics.NotificationFeedbackType) => {
+ return Platform.OS === "web"
+ ? () => {}
+ : () => Haptics.notificationAsync(type);
+ },
+ []
+ );
+
+ const hapticHandlers = useMemo(
+ () => ({
+ light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light),
+ medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium),
+ heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy),
+ selection: Platform.OS === "web" ? () => {} : Haptics.selectionAsync,
+ success: createNotificationFeedback(
+ Haptics.NotificationFeedbackType.Success
+ ),
+ warning: createNotificationFeedback(
+ Haptics.NotificationFeedbackType.Warning
+ ),
+ error: createNotificationFeedback(Haptics.NotificationFeedbackType.Error),
+ }),
+ [createHapticHandler, createNotificationFeedback]
+ );
+
+ if (settings?.disableHapticFeedback) {
+ return () => {};
+ }
+ return hapticHandlers[feedbackType];
+};
diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts
index 15aaff05..b41872dc 100644
--- a/hooks/useIntroSkipper.ts
+++ b/hooks/useIntroSkipper.ts
@@ -5,7 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import { msToSeconds, secondsToMs } from "@/utils/time";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "./useHaptic";
interface IntroTimestamps {
EpisodeId: string;
@@ -33,6 +33,7 @@ export const useIntroSkipper = (
if (isVlc) {
currentTime = msToSeconds(currentTime);
}
+ const lightHapticFeedback = useHaptic("light");
const wrappedSeek = (seconds: number) => {
if (isVlc) {
@@ -78,7 +79,7 @@ export const useIntroSkipper = (
const skipIntro = useCallback(() => {
if (!introTimestamps) return;
try {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
wrappedSeek(introTimestamps.IntroEnd);
setTimeout(() => {
play();
diff --git a/hooks/useJellyfinDiscovery.tsx b/hooks/useJellyfinDiscovery.tsx
new file mode 100644
index 00000000..963dfe81
--- /dev/null
+++ b/hooks/useJellyfinDiscovery.tsx
@@ -0,0 +1,106 @@
+import { useState, useCallback } from "react";
+import dgram from "react-native-udp";
+
+const JELLYFIN_DISCOVERY_PORT = 7359;
+const DISCOVERY_MESSAGE = "Who is JellyfinServer?";
+
+interface ServerInfo {
+ address: string;
+ port: number;
+ serverId?: string;
+ serverName?: string;
+}
+
+export const useJellyfinDiscovery = () => {
+ const [servers, setServers] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
+
+ const startDiscovery = useCallback(() => {
+ setIsSearching(true);
+ setServers([]);
+
+ const discoveredServers = new Set();
+ let discoveryTimeout: NodeJS.Timeout;
+
+ const socket = dgram.createSocket({
+ type: "udp4",
+ reusePort: true,
+ debug: __DEV__,
+ });
+
+ socket.on("error", (err) => {
+ console.error("Socket error:", err);
+ socket.close();
+ setIsSearching(false);
+ });
+
+ socket.bind(0, () => {
+ console.log("UDP socket bound successfully");
+
+ try {
+ socket.setBroadcast(true);
+ const messageBuffer = new TextEncoder().encode(DISCOVERY_MESSAGE);
+
+ socket.send(
+ messageBuffer,
+ 0,
+ messageBuffer.length,
+ JELLYFIN_DISCOVERY_PORT,
+ "255.255.255.255",
+ (err) => {
+ if (err) {
+ console.error("Failed to send discovery message:", err);
+ return;
+ }
+ console.log("Discovery message sent successfully");
+ }
+ );
+
+ discoveryTimeout = setTimeout(() => {
+ setIsSearching(false);
+ socket.close();
+ }, 5000);
+ } catch (error) {
+ console.error("Error during discovery:", error);
+ setIsSearching(false);
+ }
+ });
+
+ socket.on("message", (msg, rinfo: any) => {
+ if (discoveredServers.has(rinfo.address)) {
+ return;
+ }
+
+ try {
+ const response = new TextDecoder().decode(msg);
+ const serverInfo = JSON.parse(response);
+ discoveredServers.add(rinfo.address);
+
+ const newServer: ServerInfo = {
+ address: `http://${rinfo.address}:${serverInfo.Port || 8096}`,
+ port: serverInfo.Port || 8096,
+ serverId: serverInfo.Id,
+ serverName: serverInfo.Name,
+ };
+
+ setServers((prev) => [...prev, newServer]);
+ } catch (error) {
+ console.error("Error parsing server response:", error);
+ }
+ });
+
+ return () => {
+ clearTimeout(discoveryTimeout);
+ if (isSearching) {
+ setIsSearching(false);
+ }
+ socket.close();
+ };
+ }, []);
+
+ return {
+ servers,
+ isSearching,
+ startDiscovery,
+ };
+};
diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts
index 8393798d..e56ab277 100644
--- a/hooks/useJellyseerr.ts
+++ b/hooks/useJellyseerr.ts
@@ -28,6 +28,18 @@ import Issue from "@/utils/jellyseerr/server/entity/Issue";
import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
import { writeErrorLog } from "@/utils/log";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
+import { t } from "i18next";
+import {
+ CombinedCredit,
+ PersonDetails,
+} from "@/utils/jellyseerr/server/models/Person";
+import { useQueryClient } from "@tanstack/react-query";
+import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
+import {UserResultsResponse} from "@/utils/jellyseerr/server/interfaces/api/userInterfaces";
+import {
+ ServiceCommonServer,
+ ServiceCommonServerWithDetails
+} from "@/utils/jellyseerr/server/interfaces/api/serviceInterfaces";
interface SearchParams {
query: string;
@@ -55,19 +67,29 @@ export enum Endpoints {
API_V1 = "/api/v1",
SEARCH = "/search",
REQUEST = "/request",
+ PERSON = "/person",
+ COMBINED_CREDITS = "/combined_credits",
MOVIE = "/movie",
RATINGS = "/ratings",
ISSUE = "/issue",
+ USER = "/user",
+ SERVICE = "/service",
TV = "/tv",
SETTINGS = "/settings",
+ NETWORK = "/network",
+ STUDIO = "/studio",
+ GENRE_SLIDER = "/genreslider",
DISCOVER = "/discover",
DISCOVER_TRENDING = DISCOVER + "/trending",
DISCOVER_MOVIES = DISCOVER + "/movies",
DISCOVER_TV = DISCOVER + TV,
+ DISCOVER_TV_NETWORK = DISCOVER + TV + NETWORK,
+ DISCOVER_MOVIES_STUDIO = DISCOVER + `${MOVIE}s` + STUDIO,
AUTH_JELLYFIN = "/auth/jellyfin",
}
export type DiscoverEndpoint =
+ | Endpoints.DISCOVER_TV_NETWORK
| Endpoints.DISCOVER_TRENDING
| Endpoints.DISCOVER_MOVIES
| Endpoints.DISCOVER_TV;
@@ -113,7 +135,7 @@ export class JellyseerrApi {
if (inRange(status, 200, 299)) {
if (data.version < "2.0.0") {
const error =
- "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0";
+ t("jellyseerr.toasts.jellyseer_does_not_meet_requirements");
toast.error(error);
throw Error(error);
}
@@ -127,7 +149,7 @@ export class JellyseerrApi {
requiresPass: true,
};
}
- toast.error(`Jellyseerr test failed. Please try again.`);
+ toast.error(t("jellyseerr.toasts.jellyseerr_test_failed"));
writeErrorLog(
`Jellyseerr returned a ${status} for url:\n` +
response.config.url +
@@ -140,7 +162,7 @@ export class JellyseerrApi {
};
})
.catch((e) => {
- const msg = "Failed to test jellyseerr server url";
+ const msg = t("jellyseerr.toasts.failed_to_test_jellyseerr_server_url");
toast.error(msg);
console.error(msg, e);
return {
@@ -174,7 +196,7 @@ export class JellyseerrApi {
}
async discover(
- endpoint: DiscoverEndpoint,
+ endpoint: DiscoverEndpoint | string,
params: any
): Promise {
return this.axios
@@ -182,6 +204,15 @@ export class JellyseerrApi {
.then(({ data }) => data);
}
+ async getGenreSliders(
+ endpoint: Endpoints.TV | Endpoints.MOVIE,
+ params: any = undefined
+ ): Promise {
+ return this.axios
+ ?.get(Endpoints.API_V1 + Endpoints.DISCOVER + Endpoints.GENRE_SLIDER + endpoint, { params })
+ .then(({ data }) => data);
+ }
+
async search(params: SearchParams): Promise {
const response = await this.axios?.get(
Endpoints.API_V1 + Endpoints.SEARCH,
@@ -204,6 +235,27 @@ export class JellyseerrApi {
});
}
+ async personDetails(id: number | string): Promise {
+ return this.axios
+ ?.get(Endpoints.API_V1 + Endpoints.PERSON + `/${id}`)
+ .then((response) => {
+ return response?.data;
+ });
+ }
+
+ async personCombinedCredits(id: number | string): Promise {
+ return this.axios
+ ?.get(
+ Endpoints.API_V1 +
+ Endpoints.PERSON +
+ `/${id}` +
+ Endpoints.COMBINED_CREDITS
+ )
+ .then((response) => {
+ return response?.data;
+ });
+ }
+
async movieRatings(id: number) {
return this.axios
?.get(
@@ -238,14 +290,26 @@ export class JellyseerrApi {
});
}
- tvStillImageProxy(path: string, width: number = 1920, quality: number = 75) {
- return (
- this.axios.defaults.baseURL +
- `/_next/image?` +
- new URLSearchParams(
- `url=https://image.tmdb.org/t/p/original/${path}&w=${width}&q=${quality}`
- ).toString()
- );
+ async user(params: any) {
+ return this.axios
+ ?.get(`${Endpoints.API_V1}${Endpoints.USER}`, { params })
+ .then(({data}) => data.results)
+ }
+
+ imageProxy(
+ path?: string,
+ filter: string = "original",
+ width: number = 1920,
+ quality: number = 75
+ ) {
+ return path
+ ? this.axios.defaults.baseURL +
+ `/_next/image?` +
+ new URLSearchParams(
+ `url=https://image.tmdb.org/t/p/${filter}/${path}&w=${width}&q=${quality}`
+ ).toString()
+ : this.axios?.defaults.baseURL +
+ `/images/overseerr_poster_not_found_logo_top.png`;
}
async submitIssue(mediaId: number, issueType: IssueType, message: string) {
@@ -259,12 +323,24 @@ export class JellyseerrApi {
const issue = response.data;
if (issue.status === IssueStatus.OPEN) {
- toast.success("Issue submitted!");
+ toast.success(t("jellyseerr.toasts.issue_submitted"));
}
return issue;
});
}
+ async service(type: 'radarr' | 'sonarr') {
+ return this.axios
+ ?.get(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}`)
+ .then(({data}) => data);
+ }
+
+ async serviceDetails(type: 'radarr' | 'sonarr', id: number) {
+ return this.axios
+ ?.get(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}` + `/${id}`)
+ .then(({data}) => data);
+ }
+
private setInterceptors() {
this.axios.interceptors.response.use(
async (response) => {
@@ -321,6 +397,7 @@ const jellyseerrUserAtom = atom(storage.get(JELLYSEERR_USER));
export const useJellyseerr = () => {
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
const [settings, updateSettings] = useSettings();
+ const queryClient = useQueryClient();
const jellyseerrApi = useMemo(() => {
const cookies = storage.get(JELLYSEERR_COOKIES);
@@ -338,18 +415,22 @@ export const useJellyseerr = () => {
const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
- jellyseerrApi?.request?.(request)?.then((mediaRequest) => {
+ jellyseerrApi?.request?.(request)?.then(async (mediaRequest) => {
+ await queryClient.invalidateQueries({
+ queryKey: ["search", "jellyseerr"],
+ });
+
switch (mediaRequest.status) {
case MediaRequestStatus.PENDING:
case MediaRequestStatus.APPROVED:
- toast.success(`Requested ${title}!`);
+ toast.success(t("jellyseerr.toasts.requested_item", {item: title}));
onSuccess?.()
break;
case MediaRequestStatus.DECLINED:
- toast.error(`You don't have permission to request!`);
+ toast.error(t("jellyseerr.toasts.you_dont_have_permission_to_request"));
break;
case MediaRequestStatus.FAILED:
- toast.error(`Something went wrong requesting media!`);
+ toast.error(t("jellyseerr.toasts.something_went_wrong_requesting_media"));
break;
}
});
diff --git a/hooks/useMarkAsPlayed.ts b/hooks/useMarkAsPlayed.ts
index ff039cc8..fb30bd14 100644
--- a/hooks/useMarkAsPlayed.ts
+++ b/hooks/useMarkAsPlayed.ts
@@ -3,13 +3,14 @@ import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "./useHaptic";
import { useAtom } from "jotai";
export const useMarkAsPlayed = (item: BaseItemDto) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
+ const lightHapticFeedback = useHaptic("light");
const invalidateQueries = () => {
const queriesToInvalidate = [
@@ -29,7 +30,7 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
};
const markAsPlayedStatus = async (played: boolean) => {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
// Optimistic update
queryClient.setQueryData(
diff --git a/hooks/useOrientationSettings.ts b/hooks/useOrientationSettings.ts
index 85b8a113..907e9bf2 100644
--- a/hooks/useOrientationSettings.ts
+++ b/hooks/useOrientationSettings.ts
@@ -7,7 +7,9 @@ export const useOrientationSettings = () => {
useEffect(() => {
if (settings?.autoRotate) {
- // Don't need to do anything
+ ScreenOrientation.lockAsync(
+ ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
+ );
} else if (settings?.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts
index 25492e33..e3990965 100644
--- a/hooks/useRemuxHlsToMp4.ts
+++ b/hooks/useRemuxHlsToMp4.ts
@@ -18,6 +18,7 @@ import useDownloadHelper from "@/utils/download";
import { Api } from "@jellyfin/sdk";
import { useSettings } from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server";
+import { useTranslation } from "react-i18next";
const createFFmpegCommand = (url: string, output: string) => [
"-y", // overwrite output files without asking
@@ -49,6 +50,7 @@ export const useRemuxHlsToMp4 = () => {
const api = useAtomValue(apiAtom);
const router = useRouter();
const queryClient = useQueryClient();
+ const { t } = useTranslation();
const [settings] = useSettings();
const { saveImage } = useImageStorage();
@@ -84,7 +86,7 @@ export const useRemuxHlsToMp4 = () => {
queryKey: ["downloadedItems"],
});
saveDownloadedItemInfo(item, stat.getSize());
- toast.success("Download completed");
+ toast.success(t("home.downloads.toasts.download_completed"));
}
setProcesses((prev) => {
@@ -144,7 +146,7 @@ export const useRemuxHlsToMp4 = () => {
// First lets save any important assets we want to present to the user offline
await onSaveAssets(api, item);
- toast.success(`Download started for ${item.Name}`, {
+ toast.success(t("home.downloads.toasts.download_started_for", {item: item.Name}), {
action: {
label: "Go to download",
onClick: () => {
diff --git a/hooks/useWebsockets.ts b/hooks/useWebsockets.ts
index 75199b31..d9e6096a 100644
--- a/hooks/useWebsockets.ts
+++ b/hooks/useWebsockets.ts
@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { Alert } from "react-native";
import { useRouter } from "expo-router";
import { useWebSocketContext } from "@/providers/WebSocketProvider";
+import { useTranslation } from "react-i18next";
interface UseWebSocketProps {
isPlaying: boolean;
@@ -18,6 +19,7 @@ export const useWebSocket = ({
}: UseWebSocketProps) => {
const router = useRouter();
const { ws } = useWebSocketContext();
+ const { t } = useTranslation();
useEffect(() => {
if (!ws) return;
@@ -40,7 +42,7 @@ export const useWebSocket = ({
console.log("Command ~ DisplayMessage");
const title = json?.Data?.Arguments?.Header;
const body = json?.Data?.Arguments?.Text;
- Alert.alert("Message from server: " + title, body);
+ Alert.alert(t("player.message_from_server", {message: title}), body);
}
};
diff --git a/i18n.ts b/i18n.ts
new file mode 100644
index 00000000..edfe7202
--- /dev/null
+++ b/i18n.ts
@@ -0,0 +1,30 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+
+import en from "./translations/en.json";
+import fr from "./translations/fr.json";
+import sv from "./translations/sv.json";
+import { getLocales } from "expo-localization";
+
+export const APP_LANGUAGES = [
+ { label: "English", value: "en" },
+ { label: "Français", value: "fr" },
+ { label: "Svenska", value: "sv" },
+];
+
+i18n.use(initReactI18next).init({
+ compatibilityJSON: "v4",
+ resources: {
+ en: { translation: en },
+ fr: { translation: fr },
+ sv: { translation: sv },
+ },
+
+ lng: getLocales()[0].languageCode || "en",
+ fallbackLng: "en",
+ interpolation: {
+ escapeValue: false,
+ },
+});
+
+export default i18n;
diff --git a/package.json b/package.json
index 0f2e5f28..740d7fa3 100644
--- a/package.json
+++ b/package.json
@@ -4,96 +4,107 @@
"version": "1.0.0",
"scripts": {
"submodule-reload": "git submodule update --init --remote --recursive",
+ "clean": "echo y | expo prebuild --clean",
"start": "bun run submodule-reload && expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "bun run submodule-reload && expo run:android",
"ios": "bun run submodule-reload && expo run:ios",
"web": "bun run submodule-reload && expo start --web",
+ "test": "jest --watchAll",
"lint": "expo lint",
"postinstall": "patch-package"
},
+ "jest": {
+ "preset": "jest-expo"
+ },
"dependencies": {
- "@bottom-tabs/react-navigation": "^0.7.1",
+ "@bottom-tabs/react-navigation": "0.8.0",
+ "react-native-bottom-tabs": "0.8.0",
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.4",
"@futurejj/react-native-visibility-sensor": "^1.3.5",
"@gorhom/bottom-sheet": "^4.6.4",
"@jellyfin/sdk": "^0.11.0",
- "@kesha-antonov/react-native-background-downloader": "^3.2.1",
+ "@kesha-antonov/react-native-background-downloader": "3.2.6",
"@react-native-async-storage/async-storage": "1.23.1",
- "@react-native-community/netinfo": "11.4.1",
+ "@react-native-community/netinfo": "11.3.1",
"@react-native-menu/menu": "^1.1.6",
"@react-navigation/material-top-tabs": "^6.6.14",
- "@react-navigation/native": "^7.0.14",
- "@shopify/flash-list": "1.7.1",
+ "@react-navigation/native": "^6.1.18",
+ "@shopify/flash-list": "1.6.4",
"@tanstack/react-query": "^5.59.20",
"@types/lodash": "^4.17.13",
"@types/react-native-vector-icons": "^6.4.18",
"@types/uuid": "^10.0.0",
"add": "^2.0.6",
"axios": "^1.7.7",
- "expo": "52",
- "expo-asset": "~11.0.1",
- "expo-background-fetch": "~13.0.3",
- "expo-blur": "~14.0.1",
- "expo-brightness": "~13.0.2",
- "expo-build-properties": "~0.13.1",
- "expo-constants": "~17.0.3",
- "expo-dev-client": "~5.0.8",
- "expo-device": "~7.0.1",
- "expo-font": "~13.0.2",
- "expo-haptics": "~14.0.0",
- "expo-image": "~2.0.3",
- "expo-keep-awake": "~14.0.1",
- "expo-linear-gradient": "~14.0.1",
- "expo-linking": "~7.0.3",
- "expo-network": "~7.0.4",
- "expo-notifications": "~0.29.11",
- "expo-router": "~4.0.15",
- "expo-screen-orientation": "~8.0.2",
- "expo-sensors": "~14.0.1",
- "expo-splash-screen": "~0.29.18",
- "expo-status-bar": "~2.0.0",
- "expo-system-ui": "~4.0.6",
- "expo-task-manager": "~12.0.3",
- "expo-updates": "~0.26.10",
- "expo-web-browser": "~14.0.1",
+ "expo": "~51.0.39",
+ "expo-asset": "~10.0.10",
+ "expo-background-fetch": "~12.0.1",
+ "expo-blur": "~13.0.2",
+ "expo-brightness": "~12.0.1",
+ "expo-build-properties": "~0.12.5",
+ "expo-constants": "~16.0.2",
+ "expo-dev-client": "~4.0.29",
+ "expo-device": "~6.0.2",
+ "expo-font": "~12.0.10",
+ "expo-haptics": "~13.0.1",
+ "expo-image": "~1.13.0",
+ "expo-keep-awake": "~13.0.2",
+ "expo-linear-gradient": "~13.0.2",
+ "expo-linking": "~6.3.1",
+ "expo-localization": "~16.0.0",
+ "expo-network": "~6.0.1",
+ "expo-notifications": "~0.28.19",
+ "expo-router": "~3.5.24",
+ "expo-screen-orientation": "~7.0.5",
+ "expo-sensors": "~13.0.9",
+ "expo-splash-screen": "~0.27.7",
+ "expo-status-bar": "~1.12.1",
+ "expo-system-ui": "^3.0.7",
+ "expo-task-manager": "~11.8.2",
+ "expo-updates": "~0.25.27",
+ "expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"install": "^0.13.0",
+ "i18next": "^24.2.0",
"jotai": "^2.10.1",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
- "react": "18.3.1",
- "react-dom": "18.3.1",
- "react-native": "0.76.5",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
+ "react-i18next": "^15.4.0",
+ "react-native": "0.74.5",
"react-native-awesome-slider": "^2.5.6",
- "react-native-bottom-tabs": "0.7.1",
"react-native-circular-progress": "^1.4.1",
"react-native-compressor": "^1.9.0",
+ "react-native-country-flag": "^2.0.2",
"react-native-device-info": "^14.0.1",
- "react-native-edge-to-edge": "^1.1.1",
- "react-native-gesture-handler": "~2.20.2",
+ "react-native-edge-to-edge": "^1.1.3",
+ "react-native-gesture-handler": "~2.16.1",
"react-native-get-random-values": "^1.11.0",
"react-native-google-cast": "^4.8.3",
"react-native-image-colors": "^2.4.0",
- "react-native-mmkv": "^3.0.1",
- "react-native-pager-view": "6.5.1",
+ "react-native-ios-context-menu": "^2.5.2",
+ "react-native-ios-utilities": "4.5.3",
+ "react-native-mmkv": "^2.12.2",
+ "react-native-pager-view": "6.3.0",
"react-native-progress": "^5.0.1",
- "react-native-reanimated": "~3.16.1",
+ "react-native-reanimated": "~3.10.1",
"react-native-reanimated-carousel": "4.0.0-canary.22",
- "react-native-safe-area-context": "4.12.0",
- "react-native-screens": "~4.4.0",
- "react-native-svg": "15.8.0",
+ "react-native-safe-area-context": "4.10.5",
+ "react-native-screens": "3.31.1",
+ "react-native-svg": "15.2.0",
"react-native-tab-view": "^3.5.2",
+ "react-native-udp": "^4.1.7",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2",
"react-native-video": "^6.7.0",
"react-native-volume-manager": "^1.10.0",
"react-native-web": "~0.19.13",
- "react-native-webview": "13.12.5",
- "react-native-youtube-iframe": "^2.3.0",
+ "react-native-webview": "13.8.6",
"sonner-native": "^0.14.2",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.4",
@@ -103,8 +114,11 @@
},
"devDependencies": {
"@babel/core": "^7.26.0",
- "@types/react": "~18.3.12",
+ "@types/jest": "^29.5.14",
+ "@types/react": "~18.2.79",
"@types/react-test-renderer": "^18.0.7",
+ "jest": "^29.2.1",
+ "jest-expo": "~51.0.4",
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "18.2.0",
diff --git a/plugins/network_security_config.xml b/plugins/network_security_config.xml
new file mode 100644
index 00000000..a3e396e9
--- /dev/null
+++ b/plugins/network_security_config.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/withGoogleCastActivity.js b/plugins/withGoogleCastActivity.js
new file mode 100644
index 00000000..1a8c0a30
--- /dev/null
+++ b/plugins/withGoogleCastActivity.js
@@ -0,0 +1,34 @@
+const { withAndroidManifest } = require("@expo/config-plugins");
+
+const withGoogleCastActivity = (config) =>
+ withAndroidManifest(config, async (config) => {
+ const mainApplication = config.modResults.manifest.application[0];
+
+ // Initialize activity array if it doesn't exist
+ if (!mainApplication.activity) {
+ mainApplication.activity = [];
+ }
+
+ // Check if the activity already exists
+ const activityExists = mainApplication.activity.some(
+ (activity) =>
+ activity.$?.["android:name"] ===
+ "com.reactnative.googlecast.RNGCExpandedControllerActivity"
+ );
+
+ // Only add the activity if it doesn't already exist
+ if (!activityExists) {
+ mainApplication.activity.push({
+ $: {
+ "android:name":
+ "com.reactnative.googlecast.RNGCExpandedControllerActivity",
+ "android:theme": "@style/Theme.MaterialComponents.NoActionBar",
+ "android:launchMode": "singleTask",
+ },
+ });
+ }
+
+ return config;
+ });
+
+module.exports = withGoogleCastActivity;
diff --git a/plugins/withTrustLocalCerts.js b/plugins/withTrustLocalCerts.js
new file mode 100644
index 00000000..13b326af
--- /dev/null
+++ b/plugins/withTrustLocalCerts.js
@@ -0,0 +1,44 @@
+const { AndroidConfig, withAndroidManifest } = require("@expo/config-plugins");
+const { Paths } = require("@expo/config-plugins/build/android");
+const path = require("path");
+const fs = require("fs");
+const fsPromises = fs.promises;
+
+const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
+
+const withTrustLocalCerts = (config) => {
+ return withAndroidManifest(config, async (config) => {
+ config.modResults = await setCustomConfigAsync(config, config.modResults);
+ return config;
+ });
+};
+
+async function setCustomConfigAsync(config, androidManifest) {
+ const src_file_path = path.join(__dirname, "network_security_config.xml");
+ const res_file_path = path.join(
+ await Paths.getResourceFolderAsync(config.modRequest.projectRoot),
+ "xml",
+ "network_security_config.xml"
+ );
+
+ const res_dir = path.resolve(res_file_path, "..");
+
+ if (!fs.existsSync(res_dir)) {
+ await fsPromises.mkdir(res_dir);
+ }
+
+ try {
+ await fsPromises.copyFile(src_file_path, res_file_path);
+ } catch (e) {
+ throw new Error(
+ `Failed to copy network security config file from ${src_file_path} to ${res_file_path}: ${e.message}`
+ );
+ }
+ const mainApplication = getMainApplicationOrThrow(androidManifest);
+ mainApplication.$["android:networkSecurityConfig"] =
+ "@xml/network_security_config";
+
+ return androidManifest;
+}
+
+module.exports = withTrustLocalCerts;
diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx
index 78fbbe6f..149cdd96 100644
--- a/providers/DownloadProvider.tsx
+++ b/providers/DownloadProvider.tsx
@@ -1,4 +1,4 @@
-import { useSettings } from "@/utils/atoms/settings";
+import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import { useLog, writeToLog } from "@/utils/log";
import {
@@ -48,8 +48,9 @@ import useImageStorage from "@/hooks/useImageStorage";
import { storage } from "@/utils/mmkv";
import useDownloadHelper from "@/utils/download";
import { FileInfo } from "expo-file-system";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import * as Application from "expo-application";
+import { useTranslation } from "react-i18next";
export type DownloadedItem = {
item: Partial;
@@ -68,6 +69,7 @@ const DownloadContext = createContext(processesAtom);
+ const successHapticFeedback = useHaptic("success");
+
const authHeader = useMemo(() => {
return api?.accessToken;
}, [api]);
@@ -104,7 +108,7 @@ function useDownloadProvider() {
const url = settings?.optimizedVersionsServerUrl;
if (
- settings?.downloadMethod !== "optimized" ||
+ settings?.downloadMethod !== DownloadMethod.Optimized ||
!url ||
!deviceId ||
!authHeader
@@ -137,9 +141,9 @@ function useDownloadProvider() {
if (settings.autoDownload) {
startDownload(job);
} else {
- toast.info(`${job.item.Name} is ready to be downloaded`, {
+ toast.info(t("home.downloads.toasts.item_is_ready_to_be_downloaded",{item: job.item.Name}), {
action: {
- label: "Go to downloads",
+ label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => {
router.push("/downloads");
toast.dismiss();
@@ -164,7 +168,7 @@ function useDownloadProvider() {
},
staleTime: 0,
refetchInterval: 2000,
- enabled: settings?.downloadMethod === "optimized",
+ enabled: settings?.downloadMethod === DownloadMethod.Optimized,
});
useEffect(() => {
@@ -222,9 +226,9 @@ function useDownloadProvider() {
},
});
- toast.info(`Download started for ${process.item.Name}`, {
+ toast.info(t("home.downloads.toasts.download_stated_for_item", {item: process.item.Name}), {
action: {
- label: "Go to downloads",
+ label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => {
router.push("/downloads");
toast.dismiss();
@@ -273,10 +277,10 @@ function useDownloadProvider() {
process.item,
doneHandler.bytesDownloaded
);
- toast.success(`Download completed for ${process.item.Name}`, {
+ toast.success(t("home.downloads.toasts.download_completed_for_item", {item: process.item.Name}), {
duration: 3000,
action: {
- label: "Go to downloads",
+ label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => {
router.push("/downloads");
toast.dismiss();
@@ -298,7 +302,7 @@ function useDownloadProvider() {
if (error.errorCode === 404) {
errorMsg = "File not found on server";
}
- toast.error(`Download failed for ${process.item.Name} - ${errorMsg}`);
+ toast.error(t("home.downloads.toasts.download_failed_for_item", {item: process.item.Name, error: errorMsg}));
writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
error,
processDetails: {
@@ -355,9 +359,9 @@ function useDownloadProvider() {
throw new Error("Failed to start optimization job");
}
- toast.success(`Queued ${item.Name} for optimization`, {
+ toast.success(t("home.downloads.toasts.queued_item_for_optimization", {item: item.Name}), {
action: {
- label: "Go to download",
+ label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => {
router.push("/downloads");
toast.dismiss();
@@ -375,21 +379,21 @@ function useDownloadProvider() {
headers: error.response?.headers,
});
toast.error(
- `Failed to start download for ${item.Name}: ${error.message}`
+ t("home.downloads.toasts.failed_to_start_download_for_item", {item: item.Name, message: error.message})
);
if (error.response) {
toast.error(
- `Server responded with status ${error.response.status}`
+ t("home.downloads.toasts.server_responded_with_status", {statusCode: error.response.status})
);
} else if (error.request) {
- toast.error("No response received from server");
+ t("home.downloads.toasts.no_response_received_from_server");
} else {
toast.error("Error setting up the request");
}
} else {
console.error("Non-Axios error:", error);
toast.error(
- `Failed to start download for ${item.Name}: Unexpected error`
+ t("home.downloads.toasts.failed_to_start_download_for_item_unexpected_error", {item: item.Name})
);
}
}
@@ -405,11 +409,11 @@ function useDownloadProvider() {
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
])
.then(() =>
- toast.success("All files, folders, and jobs deleted successfully")
+ toast.success(t("home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully"))
)
.catch((reason) => {
console.error("Failed to delete all files, folders, and jobs:", reason);
- toast.error("An error occurred while deleting files and jobs");
+ toast.error(t("home.downloads.toasts.an_error_occured_while_deleting_files_and_jobs"));
});
};
@@ -532,9 +536,7 @@ function useDownloadProvider() {
if (i.Id) return deleteFile(i.Id);
return;
})
- ).then(() =>
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
- );
+ ).then(() => successHapticFeedback());
};
const cleanCacheDirectory = async () => {
diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx
index 5145c4b2..dddebb10 100644
--- a/providers/JellyfinProvider.tsx
+++ b/providers/JellyfinProvider.tsx
@@ -1,3 +1,4 @@
+import "@/augmentations";
import { useInterval } from "@/hooks/useInterval";
import { storage } from "@/utils/mmkv";
import { Api, Jellyfin } from "@jellyfin/sdk";
@@ -19,7 +20,9 @@ import React, {
import { Platform } from "react-native";
import uuid from "react-native-uuid";
import { getDeviceName } from "react-native-device-info";
-import { toast } from "sonner-native";
+import { useTranslation } from "react-i18next";
+import { useSettings } from "@/utils/atoms/settings";
+import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
interface Server {
address: string;
@@ -48,6 +51,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [jellyfin, setJellyfin] = useState(undefined);
const [deviceId, setDeviceId] = useState(undefined);
+ const { t } = useTranslation();
+
useEffect(() => {
(async () => {
const id = getOrSetDeviceId();
@@ -55,7 +60,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
- clientInfo: { name: "Streamyfin", version: "0.23.0" },
+ clientInfo: { name: "Streamyfin", version: "0.25.0" },
deviceInfo: {
name: deviceName,
id,
@@ -70,6 +75,14 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [user, setUser] = useAtom(userAtom);
const [isPolling, setIsPolling] = useState(false);
const [secret, setSecret] = useState(null);
+ const [
+ settings,
+ updateSettings,
+ pluginSettings,
+ setPluginSettings,
+ refreshStreamyfinPluginSettings,
+ ] = useSettings();
+ const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
useQuery({
queryKey: ["user", api],
@@ -92,7 +105,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
- }, DeviceId="${deviceId}", Version="0.23.0"`,
+ }, DeviceId="${deviceId}", Version="0.25.0"`,
};
}, [deviceId]);
@@ -164,6 +177,14 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
useInterval(pollQuickConnect, isPolling ? 1000 : null);
+ useEffect(() => {
+ (async () => {
+ await refreshStreamyfinPluginSettings();
+ })();
+ }, []);
+
+ useInterval(refreshStreamyfinPluginSettings, 60 * 5 * 1000); // 5 min
+
const discoverServers = async (url: string): Promise => {
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
url
@@ -226,27 +247,39 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
storage.set("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
storage.set("token", auth.data?.AccessToken);
+
+ const recentPluginSettings = await refreshStreamyfinPluginSettings();
+ if (recentPluginSettings?.jellyseerrServerUrl?.value) {
+ const jellyseerrApi = new JellyseerrApi(
+ recentPluginSettings.jellyseerrServerUrl.value
+ );
+ await jellyseerrApi.test().then((result) => {
+ if (result.isValid && result.requiresPass) {
+ jellyseerrApi.login(username, password).then(setJellyseerrUser);
+ }
+ });
+ }
}
} catch (error) {
if (axios.isAxiosError(error)) {
switch (error.response?.status) {
case 401:
- throw new Error("Invalid username or password");
+ throw new Error(t("login.invalid_username_or_password"));
case 403:
- throw new Error("User does not have permission to log in");
+ throw new Error(t("login.user_does_not_have_permission_to_log_in"));
case 408:
throw new Error(
- "Server is taking too long to respond, try again later"
+ t("login.server_is_taking_too_long_to_respond_try_again_later")
);
case 429:
throw new Error(
- "Server received too many requests, try again later"
+ t("login.server_received_too_many_requests_try_again_later")
);
case 500:
- throw new Error("There is a server error");
+ throw new Error(t("login.there_is_a_server_error"));
default:
throw new Error(
- "An unexpected error occurred. Did you enter the server URL correctly?"
+ t("login.an_unexpected_error_occured_did_you_enter_the_correct_url")
);
}
}
@@ -262,6 +295,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
mutationFn: async () => {
storage.delete("token");
setUser(null);
+ setPluginSettings(undefined);
+ await clearAllJellyseerData();
},
onError: (error) => {
console.error("Logout failed:", error);
diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx
index 50f780cd..ff80bb9e 100644
--- a/providers/PlaySettingsProvider.tsx
+++ b/providers/PlaySettingsProvider.tsx
@@ -38,7 +38,6 @@ type PlaySettingsContextType = {
setPlayUrl: React.Dispatch>;
playSessionId?: string | null;
setOfflineSettings: (data: PlaybackType) => void;
- setMusicPlaySettings: (item: BaseItemDto, url: string) => void;
};
const PlaySettingsContext = createContext(
@@ -61,13 +60,6 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
_setPlaySettings(data);
}, []);
- const setMusicPlaySettings = (item: BaseItemDto, url: string) => {
- setPlaySettings({
- item: item,
- });
- setPlayUrl(url);
- };
-
const setPlaySettings = useCallback(
async (
dataOrUpdater:
@@ -147,7 +139,6 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
setPlaySettings,
playUrl,
setPlayUrl,
- setMusicPlaySettings,
setOfflineSettings,
playSessionId,
mediaSource,
diff --git a/scripts/automerge.sh b/scripts/automerge.sh
new file mode 100755
index 00000000..d66a0941
--- /dev/null
+++ b/scripts/automerge.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+[[ -z $(git status --porcelain) ]] &&
+git checkout master &&
+git pull --ff-only &&
+git checkout develop &&
+git merge master &&
+git push --follow-tags &&
+git checkout master &&
+git merge develop --ff-only &&
+git push &&
+git checkout develop ||
+(echo "Error: Failed to merge" && exit 1)
\ No newline at end of file
diff --git a/scripts/reset-project.js b/scripts/reset-project.js
deleted file mode 100755
index 4512e162..00000000
--- a/scripts/reset-project.js
+++ /dev/null
@@ -1,73 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script is used to reset the project to a blank state.
- * It moves the /app directory to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file.
- * You can remove the `reset-project` script from package.json and safely delete this file after running it.
- */
-
-const fs = require('fs');
-const path = require('path');
-
-const root = process.cwd();
-const oldDirPath = path.join(root, 'app');
-const newDirPath = path.join(root, 'app-example');
-const newAppDirPath = path.join(root, 'app');
-
-const indexContent = `import { Text, View } from "react-native";
-
-export default function Index() {
- return (
-
- Edit app/index.tsx to edit this screen.
-
- );
-}
-`;
-
-const layoutContent = `import { Stack } from "expo-router";
-
-export default function RootLayout() {
- return (
-
-
-
- );
-}
-`;
-
-fs.rename(oldDirPath, newDirPath, (error) => {
- if (error) {
- return console.error(`Error renaming directory: ${error}`);
- }
- console.log('/app moved to /app-example.');
-
- fs.mkdir(newAppDirPath, { recursive: true }, (error) => {
- if (error) {
- return console.error(`Error creating new app directory: ${error}`);
- }
- console.log('New /app directory created.');
-
- const indexPath = path.join(newAppDirPath, 'index.tsx');
- fs.writeFile(indexPath, indexContent, (error) => {
- if (error) {
- return console.error(`Error creating index.tsx: ${error}`);
- }
- console.log('app/index.tsx created.');
-
- const layoutPath = path.join(newAppDirPath, '_layout.tsx');
- fs.writeFile(layoutPath, layoutContent, (error) => {
- if (error) {
- return console.error(`Error creating _layout.tsx: ${error}`);
- }
- console.log('app/_layout.tsx created.');
- });
- });
- });
-});
diff --git a/svenska_kyrkan.sql b/svenska_kyrkan.sql
deleted file mode 100644
index e69de29b..00000000
diff --git a/translations/en.json b/translations/en.json
new file mode 100644
index 00000000..df889eb3
--- /dev/null
+++ b/translations/en.json
@@ -0,0 +1,457 @@
+{
+ "login": {
+ "username_required": "Username is required",
+ "error_title": "Error",
+ "login_title": "Log in",
+ "login_to_title": "Log in to",
+ "username_placeholder": "Username",
+ "password_placeholder": "Password",
+ "login_button": "Log in",
+ "quick_connect": "Quick Connect",
+ "enter_code_to_login": "Enter code {{code}} to login",
+ "failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
+ "got_it": "Got it",
+ "connection_failed": "Connection failed",
+ "could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
+ "an_unexpected_error_occured": "An unexpected error occurred",
+ "change_server": "Change server",
+ "invalid_username_or_password": "Invalid username or password",
+ "user_does_not_have_permission_to_log_in": "User does not have permission to log in",
+ "server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
+ "server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
+ "there_is_a_server_error": "There is a server error",
+ "an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?"
+ },
+ "server": {
+ "enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
+ "server_url_placeholder": "http(s)://your-server.com",
+ "connect_button": "Connect",
+ "previous_servers": "previous servers",
+ "clear_button": "Clear",
+ "search_for_local_servers": "Search for local servers",
+ "searching": "Searching...",
+ "servers": "Servers"
+ },
+ "home": {
+ "no_internet": "No Internet",
+ "no_items": "No items",
+ "no_internet_message": "No worries, you can still watch\ndownloaded content.",
+ "go_to_downloads": "Go to downloads",
+ "oops": "Oops!",
+ "error_message": "Something went wrong.\nPlease log out and in again.",
+ "continue_watching": "Continue Watching",
+ "next_up": "Next Up",
+ "recently_added_in": "Recently Added in {{libraryName}}",
+ "suggested_movies": "Suggested Movies",
+ "suggested_episodes": "Suggested Episodes",
+ "intro": {
+ "welcome_to_streamyfin": "Welcome to Streamyfin",
+ "a_free_and_open_source_client_for_jellyfin": "A free and open-source client for Jellyfin.",
+ "features_title": "Features",
+ "features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
+ "jellyseerr_feature_description": "Connect to your Jellyseerr instance and request movies directly in the app.",
+ "downloads_feature_title": "Downloads",
+ "downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
+ "chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
+ "centralised_settings_plugin_title": "Centralised Settings Plugin",
+ "centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
+ "done_button": "Done",
+ "go_to_settings_button": "Go to settings",
+ "read_more": "Read more"
+ },
+ "settings": {
+ "settings_title": "Settings",
+ "log_out_button": "Log out",
+ "user_info": {
+ "user_info_title": "User Info",
+ "user": "User",
+ "server": "Server",
+ "token": "Token",
+ "app_version": "App Version"
+ },
+ "quick_connect": {
+ "quick_connect_title": "Quick Connect",
+ "authorize_button": "Authorize Quick Connect",
+ "enter_the_quick_connect_code": "Enter the quick connect code...",
+ "success": "Success",
+ "quick_connect_autorized": "Quick Connect authorized",
+ "error": "Error",
+ "invalid_code": "Invalid code",
+ "authorize": "Authorize"
+ },
+ "media_controls": {
+ "media_controls_title": "Media Controls",
+ "forward_skip_length": "Forward skip length",
+ "rewind_length": "Rewind length",
+ "seconds_unit": "s"
+ },
+ "audio": {
+ "audio_title": "Audio",
+ "set_audio_track": "Set Audio Track From Previous Item",
+ "audio_language": "Audio language",
+ "audio_hint": "Choose a default audio language.",
+ "none": "None",
+ "language": "Language"
+ },
+ "subtitles": {
+ "subtitle_title": "Subtitles",
+ "subtitle_language": "Subtitle language",
+ "subtitle_mode": "Subtitle Mode",
+ "set_subtitle_track": "Set Subtitle Track From Previous Item",
+ "subtitle_size": "Subtitle Size",
+ "subtitle_hint": "Configure subtitle preference.",
+ "none": "None",
+ "language": "Language",
+ "loading": "Loading",
+ "modes": {
+ "Default": "Default",
+ "Smart": "Smart",
+ "Always": "Always",
+ "None": "None",
+ "OnlyForced": "OnlyForced"
+ }
+ },
+ "other": {
+ "other_title": "Other",
+ "auto_rotate": "Auto rotate",
+ "video_orientation": "Video orientation",
+ "orientation": "Orientation",
+ "orientations": {
+ "DEFAULT": "Default",
+ "ALL": "All",
+ "PORTRAIT": "Portrait",
+ "PORTRAIT_UP": "Portrait Up",
+ "PORTRAIT_DOWN": "Portrait Down",
+ "LANDSCAPE": "Landscape",
+ "LANDSCAPE_LEFT": "Landscape Left",
+ "LANDSCAPE_RIGHT": "Landscape Right",
+ "OTHER": "Other",
+ "UNKNOWN": "Unknown"
+ },
+ "safe_area_in_controls": "Safe area in controls",
+ "show_custom_menu_links": "Show Custom Menu Links",
+ "hide_libraries": "Hide Libraries",
+ "select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
+ "disable_haptic_feedback": "Disable Haptic Feedback"
+ },
+ "downloads": {
+ "downloads_title": "Downloads",
+ "download_method": "Download method",
+ "remux_max_download": "Remux max download",
+ "auto_download": "Auto download",
+ "optimized_versions_server": "Optimized versions server",
+ "save_button": "Save",
+ "optimized_server": "Optimized Server",
+ "optimized": "Optimized",
+ "default": "Default",
+ "optimized_version_hint": "Enter the URL for the optimize server. The URL should include http or https and optionally the port.",
+ "read_more_about_optimized_server": "Read more about the optimize server.",
+ "url":"URL",
+ "server_url_placeholder": "http(s)://domain.org:port"
+ },
+ "plugins": {
+ "plugins_title": "Plugins",
+ "jellyseerr": {
+ "jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
+ "server_url": "Server URL",
+ "server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
+ "server_url_placeholder": "Jellyseerr URL...",
+ "password": "Password",
+ "password_placeholder": "Enter password for Jellyfin user {{username}}",
+ "save_button": "Save",
+ "clear_button": "Clear",
+ "login_button": "Login",
+ "total_media_requests": "Total media requests",
+ "movie_quota_limit": "Movie quota limit",
+ "movie_quota_days": "Movie quota days",
+ "tv_quota_limit": "TV quota limit",
+ "tv_quota_days": "TV quota days",
+ "reset_jellyseerr_config_button": "Reset Jellyseerr config",
+ "unlimited": "Unlimited"
+ },
+ "marlin_search": {
+ "enable_marlin_search": "Enable Marlin Search ",
+ "url": "URL",
+ "server_url_placeholder": "http(s)://domain.org:port",
+ "marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
+ "read_more_about_marlin": "Read more about Marlin.",
+ "save_button": "Save",
+ "toasts": {
+ "saved": "Saved"
+ }
+ }
+ },
+ "storage": {
+ "storage_title": "Storage",
+ "app_usage": "App {{usedSpace}}%",
+ "phone_usage": "Phone {{availableSpace}}%",
+ "size_used": "{{used}} of {{total}} used",
+ "delete_all_downloaded_files": "Delete All Downloaded Files"
+ },
+ "intro": {
+ "show_intro": "Show intro",
+ "reset_intro": "Reset intro"
+ },
+ "logs": {
+ "logs_title": "Logs",
+ "no_logs_available": "No logs available",
+ "delete_all_logs": "Delete all logs"
+ },
+ "languages": {
+ "title": "Languages",
+ "app_language": "App language",
+ "app_language_description": "Select the language for the app.",
+ "system": "System"
+ },
+ "toasts":{
+ "error_deleting_files": "Error deleting files",
+ "background_downloads_enabled": "Background downloads enabled",
+ "background_downloads_disabled": "Background downloads disabled",
+ "connected": "Connected",
+ "could_not_connect": "Could not connect",
+ "invalid_url": "Invalid URL"
+ }
+ },
+ "downloads": {
+ "downloads_title": "Downloads",
+ "tvseries": "TV-Series",
+ "movies": "Movies",
+ "queue": "Queue",
+ "queue_hint": "Queue and downloads will be lost on app restart",
+ "no_items_in_queue": "No items in queue",
+ "no_downloaded_items": "No downloaded items",
+ "delete_all_movies_button": "Delete all Movies",
+ "delete_all_tvseries_button": "Delete all TV-Series",
+ "delete_all_button": "Delete all",
+ "active_download": "Active download",
+ "no_active_downloads": "No active downloads",
+ "active_downloads": "Active downloads",
+ "new_app_version_requires_re_download": "New app version requires re-download",
+ "new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
+ "back": "Back",
+ "delete": "Delete",
+ "something_went_wrong": "Something went wrong",
+ "could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
+ "eta": "ETA {{eta}}",
+ "methods": "Methods",
+ "toasts": {
+ "you_are_not_allowed_to_download_files": "You are not allowed to download files.",
+ "deleted_all_movies_successfully": "Deleted all movies successfully!",
+ "failed_to_delete_all_movies": "Failed to delete all movies",
+ "deleted_all_tvseries_successfully": "Deleted all TV-Series successfully!",
+ "failed_to_delete_all_tvseries": "Failed to delete all TV-Series",
+ "download_cancelled": "Download cancelled",
+ "could_not_cancel_download": "Could not cancel download",
+ "download_completed": "Download completed",
+ "download_started_for": "Download started for {{item}}",
+ "item_is_ready_to_be_downloaded": "{{item}} is ready to be downloaded",
+ "download_stated_for_item": "Download started for {{item}}",
+ "download_failed_for_item": "Download failed for {{item}} - {{error}}",
+ "download_completed_for_item": "Download completed for {{item}}",
+ "queued_item_for_optimization": "Queued {{item}} for optimization",
+ "failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}",
+ "server_responded_with_status_code": "Server responded with status {{statusCode}}",
+ "no_response_received_from_server": "No response received from the server",
+ "error_setting_up_the_request": "Error setting up the request",
+ "failed_to_start_download_for_item_unexpected_error": "Failed to start downloading for {{item}}: Unexpected error",
+ "all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
+ "an_error_occured_while_deleting_files_and_jobs": "An error occurred while deleting files and jobs",
+ "go_to_downloads": "Go to downloads"
+ }
+ }
+ },
+ "search": {
+ "search_here": "Search here...",
+ "search": "Search...",
+ "x_items": "{{count}} items",
+ "library": "Library",
+ "discover": "Discover",
+ "no_results": "No results",
+ "no_results_found_for": "No results found for",
+ "movies": "Movies",
+ "series": "Series",
+ "episodes": "Episodes",
+ "collections": "Collections",
+ "actors": "Actors",
+ "request_movies": "Request Movies",
+ "request_series": "Request Series",
+ "recently_added": "Recently Added",
+ "recent_requests": "Recent Requests",
+ "plex_watchlist": "Plex Watchlist",
+ "trending": "Trending",
+ "popular_movies": "Popular Movies",
+ "movie_genres": "Movie Genres",
+ "upcoming_movies": "Upcoming Movies",
+ "studios": "Studios",
+ "popular_tv": "Popular TV",
+ "tv_genres": "TV Genres",
+ "upcoming_tv": "Upcoming TV",
+ "networks": "Networks",
+ "tmdb_movie_keyword": "TMDB Movie Keyword",
+ "tmdb_movie_genre": "TMDB Movie Genre",
+ "tmdb_tv_keyword": "TMDB TV Keyword",
+ "tmdb_tv_genre": "TMDB TV Genre",
+ "tmdb_search": "TMDB Search",
+ "tmdb_studio": "TMDB Studio",
+ "tmdb_network": "TMDB Network",
+ "tmdb_movie_streaming_services": "TMDB Movie Streaming Services",
+ "tmdb_tv_streaming_services": "TMDB TV Streaming Services"
+ },
+ "library": {
+ "no_items_found": "No items found",
+ "no_results": "No results",
+ "no_libraries_found": "No libraries found",
+ "item_types": {
+ "movies": "movies",
+ "series": "series",
+ "boxsets": "box sets",
+ "items": "items"
+ },
+ "options": {
+ "display": "Display",
+ "row": "Row",
+ "list": "List",
+ "image_style": "Image style",
+ "poster": "Poster",
+ "cover": "Cover",
+ "show_titles": "Show titles",
+ "show_stats": "Show stats"
+ },
+ "filters": {
+ "genres": "Genres",
+ "years": "Years",
+ "sort_by": "Sort By",
+ "sort_order": "Sort Order",
+ "tags": "Tags"
+ }
+ },
+ "favorites": {
+ "series": "Series",
+ "movies": "Movies",
+ "episodes": "Episodes",
+ "videos": "Videos",
+ "boxsets": "Boxsets",
+ "playlists": "Playlists"
+ },
+ "custom_links": {
+ "no_links": "No links"
+ },
+ "player": {
+ "error": "Error",
+ "failed_to_get_stream_url": "Failed to get the stream URL",
+ "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
+ "client_error": "Client error",
+ "could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
+ "message_from_server": "Message from server: {{message}}",
+ "video_has_finished_playing": "Video has finished playing!",
+ "no_video_source": "No video source...",
+ "next_episode": "Next Episode",
+ "refresh_tracks": "Refresh Tracks",
+ "subtitle_tracks": "Subtitle Tracks:",
+ "audio_tracks": "Audio Tracks:",
+ "playback_state": "Playback State:",
+ "no_data_available": "No data available",
+ "index": "Index:"
+ },
+ "item_card": {
+ "next_up": "Next up",
+ "no_items_to_display": "No items to display",
+ "cast_and_crew": "Cast & Crew",
+ "series": "Series",
+ "seasons": "Seasons",
+ "season": "Season",
+ "no_episodes_for_this_season": "No episodes for this season",
+ "overview": "Overview",
+ "more_with": "More with {{name}}",
+ "similar_items": "Similar items",
+ "no_similar_items_found": "No similar items found",
+ "video": "Video",
+ "more_details": "More details",
+ "quality": "Quality",
+ "audio": "Audio",
+ "subtitles": "Subtitle",
+ "show_more": "Show more",
+ "show_less": "Show less",
+ "appeared_in": "Appeared in",
+ "could_not_load_item": "Could not load item",
+ "none": "None",
+ "download": {
+ "download_season": "Download Season",
+ "download_series": "Download Series",
+ "download_episode": "Download Episode",
+ "download_movie": "Download Movie",
+ "download_x_item": "Download {{item_count}} items",
+ "download_button": "Download",
+ "using_optimized_server": "Using optimized server",
+ "using_default_method": "Using default method"
+ }
+ },
+ "live_tv": {
+ "next": "Next",
+ "previous": "Previous",
+ "live_tv": "Live TV",
+ "coming_soon": "Coming soon",
+ "on_now": "On now",
+ "shows": "Shows",
+ "movies": "Movies",
+ "sports": "Sports",
+ "for_kids": "For Kids",
+ "news": "News"
+ },
+ "jellyseerr":{
+ "confirm": "Confirm",
+ "cancel": "Cancel",
+ "yes": "Yes",
+ "whats_wrong": "What's wrong?",
+ "issue_type": "Issue type",
+ "select_an_issue": "Select an issue",
+ "types": "Types",
+ "describe_the_issue": "(optional) Describe the issue...",
+ "submit_button": "Submit",
+ "report_issue_button": "Report issue",
+ "request_button": "Request",
+ "are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
+ "failed_to_login": "Failed to login",
+ "cast": "Cast",
+ "details": "Details",
+ "status": "Status",
+ "original_title": "Original Title",
+ "series_type": "Series Type",
+ "release_dates": "Release Dates",
+ "first_air_date": "First Air Date",
+ "next_air_date": "Next Air Date",
+ "revenue": "Revenue",
+ "budget": "Budget",
+ "original_language": "Original Language",
+ "production_country": "Production Country",
+ "studios": "Studios",
+ "network": "Network",
+ "currently_streaming_on": "Currently Streaming on",
+ "advanced": "Advanced",
+ "request_as": "Request As",
+ "tags": "Tags",
+ "quality_profile": "Quality Profile",
+ "root_folder": "Root Folder",
+ "season_x": "Season {{seasons}}",
+ "season_number": "Season {{season_number}}",
+ "number_episodes": "{{episode_number}} Episodes",
+ "born": "Born",
+ "appearances": "Appearances",
+ "toasts": {
+ "jellyseer_does_not_meet_requirements": "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0",
+ "jellyseerr_test_failed": "Jellyseerr test failed. Please try again.",
+ "failed_to_test_jellyseerr_server_url": "Failed to test jellyseerr server url",
+ "issue_submitted": "Issue submitted!",
+ "requested_item": "Requested {{item}}!",
+ "you_dont_have_permission_to_request": "You don't have permission to request!",
+ "something_went_wrong_requesting_media": "Something went wrong requesting media!"
+ }
+ },
+ "tabs": {
+ "home": "Home",
+ "search": "Search",
+ "library": "Library",
+ "custom_links": "Custom Links",
+ "favorites": "Favorites"
+ }
+}
diff --git a/translations/fr.json b/translations/fr.json
new file mode 100644
index 00000000..2ceb9546
--- /dev/null
+++ b/translations/fr.json
@@ -0,0 +1,457 @@
+{
+ "login": {
+ "username_required": "Nom d'utilisateur requis",
+ "error_title": "Erreur",
+ "login_title": "Se connecter",
+ "login_to_title": "Se connecter à",
+ "username_placeholder": "Nom d'utilisateur",
+ "password_placeholder": "Mot de passe",
+ "login_button": "Se connecter",
+ "quick_connect": "Connexion Rapide",
+ "enter_code_to_login": "Entrez le code {{code}} pour vous connecter",
+ "failed_to_initiate_quick_connect": "Échec de l'initialisation de Connexion Rapide",
+ "got_it": "D'accord",
+ "connection_failed": "La connection a échouée",
+ "could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier l'URL et votre connection réseau.",
+ "an_unexpected_error_occured": "Une erreur inattendue s'est produite",
+ "change_server": "Changer de serveur",
+ "invalid_username_or_password": "Nom d'utilisateur ou mot de passe invalide",
+ "user_does_not_have_permission_to_log_in": "L'utilisateur n'a pas la permission de se connecter",
+ "server_is_taking_too_long_to_respond_try_again_later": "Le serveur met trop de temps à répondre, réessayez plus tard",
+ "server_received_too_many_requests_try_again_later": "Le serveur a reçu trop de demandes, réessayez plus tard",
+ "there_is_a_server_error": "Il y a une erreur de serveur",
+ "an_unexpected_error_occured_did_you_enter_the_correct_url": "Une erreur inattendue s'est produite. Avez-vous entré la bonne URL?"
+ },
+ "server": {
+ "enter_url_to_jellyfin_server": "Entrez l'URL de votre serveur Jellyfin",
+ "server_url_placeholder": "http(s)://votre-serveur.com",
+ "connect_button": "Connexion",
+ "previous_servers": "Serveurs précédents",
+ "clear_button": "Effacer",
+ "search_for_local_servers": "Rechercher des serveurs locaux",
+ "searching": "Recherche...",
+ "servers": "Serveurs"
+ },
+ "home": {
+ "no_internet": "Pas d'Internet",
+ "no_items": "Aucun item",
+ "no_internet_message": "Aucun problème, vous pouvez toujours regarder\nle contenu téléchargé.",
+ "go_to_downloads": "Aller aux téléchargements",
+ "oops": "Oups!",
+ "error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
+ "continue_watching": "Continuer à regarder",
+ "next_up": "À suivre",
+ "recently_added_in": "Ajoutés récemment dans {{libraryName}}",
+ "suggested_movies": "Films suggérés",
+ "suggested_episodes": "Épisodes suggérés",
+ "intro": {
+ "welcome_to_streamyfin": "Bienvenue sur Streamyfin",
+ "a_free_and_open_source_client_for_jellyfin": "Un client gratuit et open source pour Jellyfin",
+ "features_title": "Fonctionnalités",
+ "features_description": "Streamyfin possède de nombreuses fonctionnalités et s'intègre à un large éventail de logiciels que vous pouvez trouver dans le menu des paramètres, notamment:",
+ "jellyseerr_feature_description": "Connectez-vous à votre instance Jellyseerr et demandez des films directement dans l'application.",
+ "downloads_feature_title": "Téléchargements",
+ "downloads_feature_description": "Téléchargez des films et des émissions de télévision pour les regarder hors ligne. Utilisez la méthode par défaut ou installez le serveur d'optimisation pour télécharger les fichiers en arrière-plan.",
+ "chromecast_feature_description": "Diffusez des films et des émissions de télévision sur vos appareils Chromecast.",
+ "centralised_settings_plugin_title": "Plugin de paramètres centralisés",
+ "centralised_settings_plugin_description": "Configuration des paramètres d'un emplacement centralisé sur votre serveur Jellyfin. Tous les paramètres clients pour tous les utilisateurs seront synchronisés automatiquement.",
+ "done_button": "Fait",
+ "go_to_settings_button": "Allez dans les paramètres",
+ "read_more": "Lisez-en plus"
+ },
+ "settings": {
+ "settings_title": "Paramètres",
+ "log_out_button": "Déconnexion",
+ "user_info": {
+ "user_info_title": "Informations utilisateur",
+ "user": "Utilisateur",
+ "server": "Serveur",
+ "token": "Jeton",
+ "app_version": "Version de l'application"
+ },
+ "quick_connect": {
+ "quick_connect_title": "Connexion Rapide",
+ "authorize_button": "Autoriser Connexion Rapide",
+ "enter_the_quick_connect_code": "Entrez le code Connexion Rapide...",
+ "success": "Succès",
+ "quick_connect_autorized": "Connexion Rapide autorisé",
+ "error": "Erreur",
+ "invalid_code": "Code invalide",
+ "authorize": "Autoriser"
+ },
+ "media_controls": {
+ "media_controls_title": "Contrôles Média",
+ "forward_skip_length": "Durée de saut en avant",
+ "rewind_length": "Durée de retour arrière",
+ "seconds_unit": "s"
+ },
+ "audio": {
+ "audio_title": "Audio",
+ "set_audio_track": "Piste audio de l'élément précédent",
+ "audio_language": "Langue audio",
+ "audio_hint": "Choisissez une langue audio par défaut.",
+ "none": "Aucune",
+ "language": "Langage"
+ },
+ "subtitles": {
+ "subtitle_title": "Sous-titres",
+ "subtitle_language": "Langue des sous-titres",
+ "subtitle_mode": "Mode des sous-titres",
+ "set_subtitle_track": "Piste de sous-titres de l'élément précédent",
+ "subtitle_size": "Taille des sous-titres",
+ "subtitle_hint": "Configurez les préférences des sous-titres.",
+ "none": "Aucune",
+ "language": "Langage",
+ "loading": "Chargement",
+ "modes": {
+ "Default": "Par défaut",
+ "Smart": "Intelligent",
+ "Always": "Toujours",
+ "None": "Aucun",
+ "OnlyForced": "Forcés seulement"
+ }
+ },
+ "other": {
+ "other_title": "Autres",
+ "auto_rotate": "Rotation automatique",
+ "video_orientation": "Orientation vidéo",
+ "orientation": "Orientation",
+ "orientations": {
+ "DEFAULT": "Par défaut",
+ "ALL": "Toutes",
+ "PORTRAIT": "Portrait",
+ "PORTRAIT_UP": "Portrait Haut",
+ "PORTRAIT_DOWN": "Portrait Bas",
+ "LANDSCAPE": "Paysage",
+ "LANDSCAPE_LEFT": "Paysage Gauche",
+ "LANDSCAPE_RIGHT": "Paysage Droite",
+ "OTHER": "Autre",
+ "UNKNOWN": "Inconnu"
+ },
+ "safe_area_in_controls": "Zone de sécurité dans les contrôles",
+ "show_custom_menu_links": "Afficher les liens personnalisés",
+ "hide_libraries": "Cacher des bibliothèques",
+ "select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez obtenir de la table de bibliothèque et de la page d'accueil des sections.",
+ "disable_haptic_feedback": "Désactiver le retour haptique"
+ },
+ "downloads": {
+ "downloads_title": "Téléchargements",
+ "download_method": "Méthode de téléchargement",
+ "remux_max_download": "Téléchargement max remux",
+ "auto_download": "Téléchargement automatique",
+ "optimized_versions_server": "Serveur de versions optimisées",
+ "save_button": "Enregistrer",
+ "optimized_server": "Serveur optimisé",
+ "optimized": "Optimisé",
+ "default": "Par défaut",
+ "optimized_version_hint": "Entrez l'URL du serveur de versions optimisées. L'URL devrait inclure http ou https et optionnellement le port.",
+ "read_more_about_optimized_server": "Lisez-en plus sur le serveur de versions optimisées.",
+ "url": "URL",
+ "server_url_placeholder": "http(s)://domaine.org:port"
+ },
+ "plugins": {
+ "plugins_title": "Plugiciels",
+ "jellyseerr": {
+ "jellyseerr_warning": "Cette intégration est dans ses débuts. Attendez-vous à ce que des choses changent.",
+ "server_url": "URL du serveur",
+ "server_url_hint": "Exemple: http(s)://votre-domaine.url\n(ajouter le port si nécessaire)",
+ "server_url_placeholder": "URL de Jellyseerr...",
+ "password": "Mot de passe",
+ "password_placeholder": "Entrez le mot de passe pour l'utilisateur Jellyfin {{username}}",
+ "save_button": "Enregistrer",
+ "clear_button": "Effacer",
+ "login_button": "Connexion",
+ "total_media_requests": "Total de demandes de médias",
+ "movie_quota_limit": "Limite de quota de film",
+ "movie_quota_days": "Jours de quota de film",
+ "tv_quota_limit": "Limite de quota TV",
+ "tv_quota_days": "Jours de quota TV",
+ "reset_jellyseerr_config_button": "Réinitialiser la configuration Jellyseerr",
+ "unlimited": "Illimité"
+ },
+ "marlin_search": {
+ "enable_marlin_search": "Activer Marlin Search ",
+ "url": "URL",
+ "server_url_placeholder": "http(s)://domaine.org:port",
+ "marlin_search_hint": "Entrez l'URL du serveur Marlin. L'URL devrait inclure http ou https et optionnellement le port.",
+ "read_more_about_marlin": "Lisez-en plus sur Marlin.",
+ "save_button": "Enregistrer",
+ "toasts": {
+ "saved": "Enregistré"
+ }
+ }
+ },
+ "storage": {
+ "storage_title": "Stockage",
+ "app_usage": "App {{usedSpace}}%",
+ "phone_usage": "Téléphone {{availableSpace}}%",
+ "size_used": "{{used}} de {{total}} utilisés",
+ "delete_all_downloaded_files": "Supprimer tous les fichiers téléchargés"
+ },
+ "intro": {
+ "show_intro": "Afficher l'intro",
+ "reset_intro": "Réinitialiser l'intro"
+ },
+ "logs": {
+ "logs_title": "Journaux",
+ "no_logs_available": "Aucun journal disponible",
+ "delete_all_logs": "Supprimer tous les journaux"
+ },
+ "languages": {
+ "title": "Langues",
+ "app_language": "Langue de l'application",
+ "app_language_description": "Sélectionnez la langue de l'application",
+ "system": "Système"
+ },
+ "toasts":{
+ "error_deleting_files": "Erreur lors de la suppression des fichiers",
+ "background_downloads_enabled": "Téléchargements en arrière-plan activés",
+ "background_downloads_disabled": "Téléchargements en arrière-plan désactivés",
+ "connected": "Connecté",
+ "could_not_connect": "Impossible de se connecter",
+ "invalid_url": "URL invalide"
+ }
+ },
+ "downloads": {
+ "downloads_title": "Téléchargements",
+ "tvseries": "Séries TV",
+ "movies": "Films",
+ "queue": "File d'attente",
+ "queue_hint": "La file d'attente et les téléchargements seront perdus au redémarrage de l'application",
+ "no_items_in_queue": "Aucun item dans la file d'attente",
+ "no_downloaded_items": "Aucun item téléchargé",
+ "delete_all_movies_button": "Supprimer tous les films",
+ "delete_all_tvseries_button": "Supprimer toutes les séries",
+ "delete_all_button": "Supprimer tout",
+ "active_download": "Téléchargement actif",
+ "no_active_downloads": "Aucun téléchargements actifs",
+ "active_downloads": "Téléchargements actifs",
+ "new_app_version_requires_re_download": "La nouvelle version de l'application nécessite un nouveau téléchargement",
+ "new_app_version_requires_re_download_description": "Une nouvelle version de l'application est disponible. Veuillez supprimer tous les téléchargements et redémarrer l'application pour télécharger à nouveau",
+ "back": "Retour",
+ "delete": "Supprimer",
+ "something_went_wrong": "Quelque chose s'est mal passé",
+ "could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin",
+ "eta": "ETA {{eta}}",
+ "methods": "Méthodes",
+ "toasts": {
+ "you_are_not_allowed_to_download_files": "Vous n'êtes pas autorisé à télécharger des fichiers",
+ "deleted_all_movies_successfully": "Tous les films ont été supprimés avec succès!",
+ "failed_to_delete_all_movies": "Échec de la suppression de tous les films",
+ "deleted_all_tvseries_successfully": "Toutes les séries ont été supprimées avec succès!",
+ "failed_to_delete_all_tvseries": "Échec de la suppression de toutes les séries",
+ "download_cancelled": "Téléchargement annulé",
+ "could_not_cancel_download": "Impossible d'annuler le téléchargement",
+ "download_completed": "Téléchargement terminé",
+ "download_started_for": "Téléchargement démarré pour {{item}}",
+ "item_is_ready_to_be_downloaded": "{{item}} est prêt à être téléchargé",
+ "download_stated_for_item": "Téléchargement démarré pour {{item}}",
+ "download_failed_for_item": "Échec du téléchargement pour {{item}} - {{error}}",
+ "download_completed_for_item": "Téléchargement terminé pour {{item}}",
+ "queued_item_for_optimization": "{{item}} mis en file d'attente pour l'optimisation",
+ "failed_to_start_download_for_item": "Échec du démarrage du téléchargement pour {{item}}: {{message}}",
+ "server_responded_with_status_code": "Le serveur a répondu avec le code de statut {{statusCode}}",
+ "no_response_received_from_server": "Aucune réponse reçue du serveur",
+ "error_setting_up_the_request": "Erreur lors de la configuration de la demande",
+ "failed_to_start_download_for_item_unexpected_error": "Échec du démarrage du téléchargement pour {{item}}: Erreur inattendue",
+ "all_files_folders_and_jobs_deleted_successfully": "Tous les fichiers, dossiers et travaux ont été supprimés avec succès",
+ "an_error_occured_while_deleting_files_and_jobs": "Une erreur s'est produite lors de la suppression des fichiers et des travaux",
+ "go_to_downloads": "Aller aux téléchargements"
+ }
+ }
+ },
+ "search": {
+ "search_here": "Rechercher ici...",
+ "search": "Rechercher...",
+ "x_items": "{{count}} items",
+ "library": "Bibliothèque",
+ "discover": "Découvrir",
+ "no_results": "Aucun résultat",
+ "no_results_found_for": "Aucun résultat trouvé pour",
+ "movies": "Films",
+ "series": "Séries",
+ "episodes": "Épisodes",
+ "collections": "Collections",
+ "actors": "Acteurs",
+ "request_movies": "Demander un film",
+ "request_series": "Demander une série",
+ "recently_added": "Ajoutés récemment",
+ "recent_requests": "Demandes récentes",
+ "plex_watchlist": "Liste de lecture Plex",
+ "trending": "Tendance",
+ "popular_movies": "Films populaires",
+ "movie_genres": "Genres de films",
+ "upcoming_movies": "Films à venir",
+ "studios": "Studios",
+ "popular_tv": "TV populaire",
+ "tv_genres": "Genres TV",
+ "upcoming_tv": "TV à venir",
+ "networks": "Réseaux",
+ "tmdb_movie_keyword": "Mot-clé Films TMDB",
+ "tmdb_movie_genre": "Genre de film TMDB",
+ "tmdb_tv_keyword": "Mot-clé TV TMDB",
+ "tmdb_tv_genre": "Genre TV TMDB",
+ "tmdb_search": "Recherche TMDB",
+ "tmdb_studio": "Studio TMDB",
+ "tmdb_network": "Réseau TMDB",
+ "tmdb_movie_streaming_services": "Services de streaming de films TMDB",
+ "tmdb_tv_streaming_services": "Services de streaming TV TMDB"
+ },
+ "library": {
+ "no_items_found": "Aucun item trouvé",
+ "no_results": "Aucun résultat",
+ "no_libraries_found": "Aucune bibliothèque trouvée",
+ "item_types": {
+ "movies": "films",
+ "series": "séries",
+ "boxsets": "coffrets",
+ "items": "items"
+ },
+ "options": {
+ "display": "Affichage",
+ "row": "Rangée",
+ "list": "Liste",
+ "image_style": "Style d'image",
+ "poster": "Affiche",
+ "cover": "Couverture",
+ "show_titles": "Afficher les titres",
+ "show_stats": "Afficher les statistiques"
+ },
+ "filters": {
+ "genres": "Genres",
+ "years": "Années",
+ "sort_by": "Trier par",
+ "sort_order": "Ordre de tri",
+ "tags": "Tags"
+ }
+ },
+ "favorites": {
+ "series": "Séries",
+ "movies": "Films",
+ "episodes": "Épisodes",
+ "videos": "Vidéos",
+ "boxsets": "Coffrets",
+ "playlists": "Listes de lecture"
+ },
+ "custom_links": {
+ "no_links": "Aucun lien"
+ },
+ "player": {
+ "error": "Erreur",
+ "failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
+ "an_error_occured_while_playing_the_video": "Une erreur s'est produite lors de la lecture de la vidéo",
+ "client_error": "Erreur client",
+ "could_not_create_stream_for_chromecast": "Impossible de créer un flux pour Chromecast",
+ "message_from_server": "Message du serveur: {{message}}",
+ "video_has_finished_playing": "La vidéo a fini de jouer!",
+ "no_video_source": "Aucune source vidéo...",
+ "next_episode": "Épisode suivant",
+ "refresh_tracks": "Rafraîchir les pistes",
+ "subtitle_tracks": "Pistes de sous-titres:",
+ "audio_tracks": "Pistes audio:",
+ "playback_state": "État de lecture:",
+ "no_data_available": "Aucune donnée disponible",
+ "index": "Index:"
+ },
+ "item_card": {
+ "next_up": "À suivre",
+ "no_items_to_display": "Aucun item à afficher",
+ "cast_and_crew": "Distribution et équipe",
+ "series": "Séries",
+ "seasons": "Saisons",
+ "season": "Saison",
+ "no_episodes_for_this_season": "Aucun épisode pour cette saison",
+ "overview": "Aperçu",
+ "more_with": "Plus avec {{name}}",
+ "similar_items": "Items similaires",
+ "no_similar_items_found": "Aucun item similaire trouvé",
+ "video": "Vidéo",
+ "more_details": "Plus de détails",
+ "quality": "Qualité",
+ "audio": "Audio",
+ "subtitles": "Sous-titres",
+ "show_more": "Afficher plus",
+ "show_less": "Afficher moins",
+ "appeared_in": "Apparu dans",
+ "could_not_load_item": "Impossible de charger l'item",
+ "none": "Aucun",
+ "download": {
+ "download_season": "Télécharger la saison",
+ "download_series": "Télécharger la série",
+ "download_episode": "Télécharger l'épisode",
+ "download_movie": "Télécharger le film",
+ "download_x_item": "Télécharger {{item_count}} items",
+ "download_button": "Télécharger",
+ "using_optimized_server": "Avec le serveur de versions optimisées",
+ "using_default_method": "Avec la méthode par défaut"
+ }
+ },
+ "live_tv": {
+ "next": "Suivant",
+ "previous": "Précédent",
+ "live_tv": "TV en direct",
+ "coming_soon": "Bientôt",
+ "on_now": "En ce moment",
+ "shows": "Émissions",
+ "movies": "Films",
+ "sports": "Sports",
+ "for_kids": "Pour enfants",
+ "news": "Actualités"
+ },
+ "jellyseerr":{
+ "confirm": "Confirmer",
+ "cancel": "Annuler",
+ "yes": "Oui",
+ "whats_wrong": "Qu'est-ce qui ne va pas?",
+ "issue_type": "Type de problème",
+ "select_an_issue": "Sélectionnez un problème",
+ "types": "Types",
+ "describe_the_issue": "(optionnel) Décrivez le problème...",
+ "submit_button": "Soumettre",
+ "report_issue_button": "Signaler un problème",
+ "request_button": "Demander",
+ "are_you_sure_you_want_to_request_all_seasons": "Êtes-vous sûr de vouloir demander toutes les saisons?",
+ "failed_to_login": "Échec de la connexion",
+ "cast": "Distribution",
+ "details": "Détails",
+ "status": "Statut",
+ "original_title": "Titre original",
+ "series_type": "Type de série",
+ "release_dates": "Dates de sortie",
+ "first_air_date": "Date de première diffusion",
+ "next_air_date": "Date de prochaine diffusion",
+ "revenue": "Revenu",
+ "budget": "Budget",
+ "original_language": "Langue originale",
+ "production_country": "Pays de production",
+ "studios": "Studios",
+ "network": "Réseaux",
+ "currently_streaming_on": "En diffusion continue sur",
+ "advanced": "Avancé",
+ "request_as": "Demander en tant que",
+ "tags": "Tags",
+ "quality_profile": "Profil de qualité",
+ "root_folder": "Dossier racine",
+ "season_x": "Saison {{seasons}}",
+ "season_number": "Saison {{season_number}}",
+ "number_episodes": "{{episode_number}} épisodes",
+ "born": "Né(e) le",
+ "appearances": "Apparitions",
+ "toasts": {
+ "jellyseer_does_not_meet_requirements": "Jellyseer ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.",
+ "jellyseerr_test_failed": "Échec du test de Jellyseerr",
+ "failed_to_test_jellyseerr_server_url": "Échec du test de l'URL du serveur Jellyseerr",
+ "issue_submitted": "Problème soumis!",
+ "requested_item": "{{item}}} demandé!",
+ "you_dont_have_permission_to_request": "Vous n'avez pas la permission de demander {{item}}",
+ "something_went_wrong_requesting_media": "Quelque chose s'est mal passé en demandant le média!"
+ }
+ },
+ "tabs": {
+ "home": "Accueil",
+ "search": "Recherche",
+ "library": "Bibliothèque",
+ "custom_links": "Liens personnalisés",
+ "favorites": "Favoris"
+ }
+}
diff --git a/translations/sv.json b/translations/sv.json
new file mode 100644
index 00000000..d35f6c82
--- /dev/null
+++ b/translations/sv.json
@@ -0,0 +1,30 @@
+{
+ "login": {
+ "username_required": "Användarnamn krävs",
+ "error_title": "Fel",
+ "login_title": "Logga in",
+ "username_placeholder": "Användarnamn",
+ "password_placeholder": "Lösenord",
+ "login_button": "Logga in"
+ },
+ "server": {
+ "server_url_placeholder": "Server URL",
+ "connect_button": "Anslut"
+ },
+ "home": {
+ "home": "Hem",
+ "no_internet": "Ingen Internet",
+ "no_internet_message": "Ingen fara, du kan fortfarande titta\npå nedladdat innehåll.",
+ "go_to_downloads": "Gå till nedladdningar",
+ "oops": "Hoppsan!",
+ "error_message": "Något gick fel.\nLogga ut och in igen.",
+ "continue_watching": "Fortsätt titta",
+ "next_up": "Nästa upp",
+ "recently_added_in": "Nyligen tillagt i {{libraryName}}"
+ },
+ "tabs": {
+ "home": "Hem",
+ "search": "Sök",
+ "library": "Bibliotek"
+ }
+}
diff --git a/utils/_jellyseerr/useJellyseerrCanRequest.ts b/utils/_jellyseerr/useJellyseerrCanRequest.ts
new file mode 100644
index 00000000..c58a8928
--- /dev/null
+++ b/utils/_jellyseerr/useJellyseerrCanRequest.ts
@@ -0,0 +1,67 @@
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import {
+ MediaRequestStatus,
+ MediaStatus,
+} from "@/utils/jellyseerr/server/constants/media";
+import {
+ hasPermission,
+ Permission,
+} from "@/utils/jellyseerr/server/lib/permissions";
+import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
+import { useMemo } from "react";
+import MediaRequest from "../jellyseerr/server/entity/MediaRequest";
+import { MovieDetails } from "../jellyseerr/server/models/Movie";
+import { TvDetails } from "../jellyseerr/server/models/Tv";
+
+export const useJellyseerrCanRequest = (
+ item?: MovieResult | TvResult | MovieDetails | TvDetails
+) => {
+ const { jellyseerrUser } = useJellyseerr();
+
+ const canRequest = useMemo(() => {
+ if (!jellyseerrUser || !item) return false;
+
+ const canNotRequest =
+ item?.mediaInfo?.requests?.some(
+ (r: MediaRequest) =>
+ r.status == MediaRequestStatus.PENDING ||
+ r.status == MediaRequestStatus.APPROVED
+ ) ||
+ item.mediaInfo?.status === MediaStatus.AVAILABLE ||
+ item.mediaInfo?.status === MediaStatus.BLACKLISTED ||
+ item.mediaInfo?.status === MediaStatus.PENDING ||
+ item.mediaInfo?.status === MediaStatus.PROCESSING;
+
+ if (canNotRequest) return false;
+
+ const userHasPermission = hasPermission(
+ [
+ Permission.REQUEST,
+ item?.mediaInfo?.mediaType
+ ? Permission.REQUEST_MOVIE
+ : Permission.REQUEST_TV,
+ ],
+ jellyseerrUser.permissions,
+ { type: "or" }
+ );
+
+ return userHasPermission && !canNotRequest;
+ }, [item, jellyseerrUser]);
+
+ const hasAdvancedRequestPermission = useMemo(() => {
+ if (!jellyseerrUser) return false;
+
+ return hasPermission(
+ [
+ Permission.REQUEST_ADVANCED,
+ Permission.MANAGE_REQUESTS
+ ],
+ jellyseerrUser.permissions,
+ {type: 'or'}
+ )
+ },
+ [jellyseerrUser]
+ );
+
+ return [canRequest, hasAdvancedRequestPermission];
+};
diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts
index c37dd4eb..7483c039 100644
--- a/utils/atoms/settings.ts
+++ b/utils/atoms/settings.ts
@@ -1,12 +1,21 @@
import { atom, useAtom } from "jotai";
-import { useEffect } from "react";
+import { useCallback, useEffect, useMemo } from "react";
import * as ScreenOrientation from "expo-screen-orientation";
import { storage } from "../mmkv";
import { Platform } from "react-native";
import {
CultureDto,
SubtitlePlaybackMode,
+ ItemSortBy,
+ SortOrder,
+ BaseItemKind,
+ ItemFilter,
} from "@jellyfin/sdk/lib/generated-client";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { writeInfoLog } from "@/utils/log";
+
+const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004";
+const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS";
export type DownloadQuality = "original" | "high" | "low";
@@ -19,16 +28,16 @@ export const ScreenOrientationEnum: Record<
ScreenOrientation.OrientationLock,
string
> = {
- [ScreenOrientation.OrientationLock.DEFAULT]: "Default",
- [ScreenOrientation.OrientationLock.ALL]: "All",
- [ScreenOrientation.OrientationLock.PORTRAIT]: "Portrait",
- [ScreenOrientation.OrientationLock.PORTRAIT_UP]: "Portrait Up",
- [ScreenOrientation.OrientationLock.PORTRAIT_DOWN]: "Portrait Down",
- [ScreenOrientation.OrientationLock.LANDSCAPE]: "Landscape",
- [ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "Landscape Left",
- [ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: "Landscape Right",
- [ScreenOrientation.OrientationLock.OTHER]: "Other",
- [ScreenOrientation.OrientationLock.UNKNOWN]: "Unknown",
+ [ScreenOrientation.OrientationLock.DEFAULT]: "home.settings.other.orientations.DEFAULT",
+ [ScreenOrientation.OrientationLock.ALL]: "home.settings.other.orientations.ALL",
+ [ScreenOrientation.OrientationLock.PORTRAIT]: "home.settings.other.orientations.PORTRAIT",
+ [ScreenOrientation.OrientationLock.PORTRAIT_UP]: "home.settings.other.orientations.PORTRAIT_UP",
+ [ScreenOrientation.OrientationLock.PORTRAIT_DOWN]: "home.settings.other.orientations.PORTRAIT_DOWN",
+ [ScreenOrientation.OrientationLock.LANDSCAPE]: "home.settings.other.orientations.LANDSCAPE",
+ [ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "home.settings.other.orientations.LANDSCAPE_LEFT",
+ [ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: "home.settings.other.orientations.LANDSCAPE_RIGHT",
+ [ScreenOrientation.OrientationLock.OTHER]: "home.settings.other.orientations.OTHER",
+ [ScreenOrientation.OrientationLock.UNKNOWN]: "home.settings.other.orientations.UNKNOWN",
};
export const DownloadOptions: DownloadOption[] = [
@@ -59,12 +68,46 @@ export type DefaultLanguageOption = {
label: string;
};
+export enum DownloadMethod {
+ Remux = "remux",
+ Optimized = "optimized",
+}
+
+export type Home = {
+ sections: Array;
+};
+
+export type HomeSection = {
+ orientation?: "horizontal" | "vertical";
+ items?: HomeSectionItemResolver;
+ nextUp?: HomeSectionNextUpResolver;
+};
+
+export type HomeSectionItemResolver = {
+ title?: string;
+ sortBy?: Array;
+ sortOrder?: Array;
+ includeItemTypes?: Array;
+ genres?: Array;
+ parentId?: string;
+ limit?: number;
+ filters?: Array;
+};
+
+export type HomeSectionNextUpResolver = {
+ parentId?: string;
+ limit?: number;
+ enableResumable?: boolean;
+ enableRewatching?: boolean;
+};
+
export type Settings = {
+ home?: Home | null;
autoRotate?: boolean;
forceLandscapeInVideoPlayer?: boolean;
- usePopularPlugin?: boolean;
deviceProfile?: "Expo" | "Native" | "Old";
mediaListCollectionIds?: string[];
+ preferedLanguage?: string;
searchEngine: "Marlin" | "Jellyfin";
marlinServerUrl?: string;
openInVLC?: boolean;
@@ -81,22 +124,37 @@ export type Settings = {
forwardSkipTime: number;
rewindSkipTime: number;
optimizedVersionsServerUrl?: string | null;
- downloadMethod: "optimized" | "remux";
+ downloadMethod: DownloadMethod;
autoDownload: boolean;
showCustomMenuLinks: boolean;
+ disableHapticFeedback: boolean;
subtitleSize: number;
remuxConcurrentLimit: 1 | 2 | 3 | 4;
safeAreaInControlsEnabled: boolean;
jellyseerrServerUrl?: string;
+ hiddenLibraries?: string[];
+};
+
+export interface Lockable {
+ locked: boolean;
+ value: T;
+}
+
+export type PluginLockableSettings = {
+ [K in keyof Settings]: Lockable;
+};
+export type StreamyfinPluginConfig = {
+ settings: PluginLockableSettings;
};
const loadSettings = (): Settings => {
const defaultValues: Settings = {
+ home: null,
autoRotate: true,
forceLandscapeInVideoPlayer: false,
- usePopularPlugin: false,
deviceProfile: "Expo",
mediaListCollectionIds: [],
+ preferedLanguage: undefined,
searchEngine: "Jellyfin",
marlinServerUrl: "",
openInVLC: false,
@@ -119,13 +177,15 @@ const loadSettings = (): Settings => {
forwardSkipTime: 30,
rewindSkipTime: 10,
optimizedVersionsServerUrl: null,
- downloadMethod: "remux",
+ downloadMethod: DownloadMethod.Remux,
autoDownload: false,
showCustomMenuLinks: false,
+ disableHapticFeedback: false,
subtitleSize: Platform.OS === "ios" ? 60 : 100,
remuxConcurrentLimit: 1,
safeAreaInControlsEnabled: true,
jellyseerrServerUrl: undefined,
+ hiddenLibraries: [],
};
try {
@@ -140,22 +200,56 @@ const loadSettings = (): Settings => {
}
};
+const EXCLUDE_FROM_SAVE = ["home"];
+
const saveSettings = (settings: Settings) => {
+ Object.keys(settings).forEach((key) => {
+ if (EXCLUDE_FROM_SAVE.includes(key)) {
+ delete settings[key as keyof Settings];
+ }
+ });
const jsonValue = JSON.stringify(settings);
storage.set("settings", jsonValue);
};
export const settingsAtom = atom(null);
+export const pluginSettingsAtom = atom(
+ storage.get(STREAMYFIN_PLUGIN_SETTINGS)
+);
export const useSettings = () => {
- const [settings, setSettings] = useAtom(settingsAtom);
+ const [api] = useAtom(apiAtom);
+ const [_settings, setSettings] = useAtom(settingsAtom);
+ const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom);
useEffect(() => {
- if (settings === null) {
+ if (_settings === null) {
const loadedSettings = loadSettings();
setSettings(loadedSettings);
}
- }, [settings, setSettings]);
+ }, [_settings, setSettings]);
+
+ const setPluginSettings = useCallback(
+ (settings: PluginLockableSettings | undefined) => {
+ storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings);
+ _setPluginSettings(settings);
+ },
+ [_setPluginSettings]
+ );
+
+ const refreshStreamyfinPluginSettings = useCallback(async () => {
+ if (!api) return;
+ const settings = await api.getStreamyfinPluginConfig().then(
+ ({ data }) => {
+ writeInfoLog(`Got remote settings`);
+ return data?.settings;
+ },
+ (err) => undefined
+ );
+
+ setPluginSettings(settings);
+ return settings;
+ }, [api]);
const updateSettings = (update: Partial) => {
if (settings) {
@@ -166,5 +260,53 @@ export const useSettings = () => {
}
};
- return [settings, updateSettings] as const;
+ // We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
+ // If admin sets locked to false but provides a value,
+ // use user settings first and fallback on admin setting if required.
+ const settings: Settings = useMemo(() => {
+ let unlockedPluginDefaults = {} as Settings;
+ const overrideSettings = Object.entries(pluginSettings || {}).reduce(
+ (acc, [key, setting]) => {
+ if (setting) {
+ const { value, locked } = setting;
+
+ // Make sure we override default settings with plugin settings when they are not locked.
+ // Admin decided what users defaults should be and grants them the ability to change them too.
+ if (
+ locked === false &&
+ value &&
+ _settings?.[key as keyof Settings] !== value
+ ) {
+ unlockedPluginDefaults = Object.assign(unlockedPluginDefaults, {
+ [key as keyof Settings]: value,
+ });
+ }
+
+ acc = Object.assign(acc, {
+ [key]: locked ? value : _settings?.[key as keyof Settings] ?? value,
+ });
+ }
+ return acc;
+ },
+ {} as Settings
+ );
+
+ // Update settings with plugin defined defaults
+ if (Object.keys(unlockedPluginDefaults).length > 0) {
+ updateSettings(unlockedPluginDefaults);
+ }
+
+ return {
+ ..._settings,
+ ...overrideSettings,
+ };
+ }, [_settings, pluginSettings]);
+
+ return [
+ settings,
+ updateSettings,
+ pluginSettings,
+ setPluginSettings,
+ refreshStreamyfinPluginSettings,
+ ] as const;
};
diff --git a/utils/collectionTypeToItemType.ts b/utils/collectionTypeToItemType.ts
index 64c23ff0..f37fe5f4 100644
--- a/utils/collectionTypeToItemType.ts
+++ b/utils/collectionTypeToItemType.ts
@@ -10,8 +10,6 @@ import {
* readonly Unknown: "unknown";
readonly Movies: "movies";
readonly Tvshows: "tvshows";
- readonly Music: "music";
- readonly Musicvideos: "musicvideos";
readonly Trailers: "trailers";
readonly Homevideos: "homevideos";
readonly Boxsets: "boxsets";
@@ -33,8 +31,6 @@ export const colletionTypeToItemType = (
return BaseItemKind.Series;
case CollectionType.Homevideos:
return BaseItemKind.Video;
- case CollectionType.Musicvideos:
- return BaseItemKind.MusicVideo;
case CollectionType.Books:
return BaseItemKind.Book;
case CollectionType.Playlists:
diff --git a/utils/jellyseerr b/utils/jellyseerr
index e69d160e..4401b164 160000
--- a/utils/jellyseerr
+++ b/utils/jellyseerr
@@ -1 +1 @@
-Subproject commit e69d160e25f0962cd77b01c861ce248050e1ad38
+Subproject commit 4401b16414af604a7372dacac326c38b18ad8555
diff --git a/utils/log.tsx b/utils/log.tsx
index 7c432406..45999062 100644
--- a/utils/log.tsx
+++ b/utils/log.tsx
@@ -1,7 +1,7 @@
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import { storage } from "./mmkv";
-import {useQuery} from "@tanstack/react-query";
-import React, {createContext, useContext} from "react";
+import { useQuery } from "@tanstack/react-query";
+import React, { createContext, useContext } from "react";
type LogLevel = "INFO" | "WARN" | "ERROR";
@@ -19,10 +19,12 @@ const mmkvStorage = createJSONStorage(() => ({
}));
const logsAtom = atomWithStorage("logs", [], mmkvStorage);
-const LogContext = createContext | null>(null);
-const DownloadContext = createContext | null>(null);
+const LogContext = createContext | null>(
+ null
+);
+const DownloadContext = createContext | null>(
+ null
+);
function useLogProvider() {
const { data: logs } = useQuery({
@@ -32,11 +34,10 @@ function useLogProvider() {
});
return {
- logs
- }
+ logs,
+ };
}
-
export const writeToLog = (level: LogLevel, message: string, data?: any) => {
const newEntry: LogEntry = {
timestamp: new Date().toISOString(),
@@ -53,10 +54,13 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => {
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
storage.set("logs", JSON.stringify(recentLogs));
+ console.log(message);
};
-export const writeInfoLog = (message: string, data?: any) => writeToLog("INFO", message, data);
-export const writeErrorLog = (message: string, data?: any) => writeToLog("ERROR", message, data);
+export const writeInfoLog = (message: string, data?: any) =>
+ writeToLog("INFO", message, data);
+export const writeErrorLog = (message: string, data?: any) =>
+ writeToLog("ERROR", message, data);
export const readFromLog = (): LogEntry[] => {
const logs = storage.getString("logs");
@@ -75,14 +79,10 @@ export function useLog() {
return context;
}
-export function LogProvider({children}: { children: React.ReactNode }) {
+export function LogProvider({ children }: { children: React.ReactNode }) {
const provider = useLogProvider();
- return (
-
- {children}
-
- )
+ return {children};
}
export default logsAtom;
diff --git a/utils/useReactNavigationQuery.ts b/utils/useReactNavigationQuery.ts
new file mode 100644
index 00000000..a0c5b307
--- /dev/null
+++ b/utils/useReactNavigationQuery.ts
@@ -0,0 +1,32 @@
+import { useFocusEffect } from "@react-navigation/core";
+import {
+ QueryKey,
+ useQuery,
+ UseQueryOptions,
+ UseQueryResult,
+} from "@tanstack/react-query";
+import { useCallback } from "react";
+
+export function useReactNavigationQuery<
+ TQueryFnData = unknown,
+ TError = unknown,
+ TData = TQueryFnData,
+ TQueryKey extends QueryKey = QueryKey
+>(
+ options: UseQueryOptions
+): UseQueryResult {
+ const useQueryReturn = useQuery(options);
+
+ useFocusEffect(
+ useCallback(() => {
+ if (
+ ((options.refetchOnWindowFocus && useQueryReturn.isStale) ||
+ options.refetchOnWindowFocus === "always") &&
+ options.enabled !== false
+ )
+ useQueryReturn.refetch();
+ }, [options.enabled, options.refetchOnWindowFocus])
+ );
+
+ return useQueryReturn;
+}