Compare commits

...

99 Commits

Author SHA1 Message Date
Fredrik Burmester
252fc4387b chore 2025-01-23 11:29:35 +01:00
Fredrik Burmester
3e299e2136 fix: early return causing crash 2025-01-23 10:07:21 +01:00
Fredrik Burmester
01cab2277e Merge pull request #451 from RodoMa92/add_self_signed_support
[android] Trust android local CA store for self signed certificates
2025-01-23 10:02:16 +01:00
Fredrik Burmester
e4f4e861e0 Merge pull request #340 from simoncaron/feat/i18n
Implement translation with i18next
2025-01-23 10:01:13 +01:00
Marco Rodolfi
4d665013f0 [android] Trust android local CA store for self signed certificates 2025-01-22 20:08:20 +01:00
sarendsen
9aa4ea4a2e refactor: home section lists 2025-01-22 07:27:08 +01:00
sarendsen
93ae03f55c fix #446 2025-01-20 10:51:39 +01:00
Fredrik Burmester
b311ac98a7 Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-01-17 07:43:23 +01:00
Fredrik Burmester
83d425b2fb chore 2025-01-17 07:43:04 +01:00
Simon Caron
007fbdd0a3 Merge branch 'develop' into feat/i18n 2025-01-16 20:38:00 -05:00
Simon Caron
37df999db5 Merge pull request #1 from Gauvino/fix-typo
fix(i18n): missing typo and comma
2025-01-16 20:35:46 -05:00
sarendsen
72b9675df4 feat: Implement nextup for custom home 2025-01-16 10:36:20 +01:00
lostb1t
7a30a63335 Update README.md 2025-01-15 09:13:03 +01:00
sarendsen
0ff0fab3f4 fix: fix horizontal shows 2025-01-15 00:47:10 +01:00
Fredrik Burmester
d9d9b0ee00 Merge pull request #430 from streamyfin/feat/refreshsettings
feat: Refresh remote settings
2025-01-14 16:29:00 +01:00
Uruk
fdaa69a787 fix(i18n): missing typo and comma 2025-01-14 13:51:43 +01:00
sarendsen
ed5403e597 wip 2025-01-14 10:37:20 +01:00
sarendsen
e6f290b85f wip 2025-01-14 10:35:21 +01:00
sarendsen
aa20d9c701 wip 2025-01-14 10:31:16 +01:00
sarendsen
e7128afb32 wip 2025-01-14 09:48:17 +01:00
sarendsen
a24b126539 wip 2025-01-14 09:24:31 +01:00
sarendsen
e1fe20db86 wip 2025-01-14 07:56:32 +01:00
Simon Caron
cd9f6aa8bd update submodule 2025-01-14 00:06:14 -05:00
Simon Caron
747bd1b416 Merge branch 'develop' into feat/i18n 2025-01-13 22:35:05 -05:00
Simon Caron
364ce46fe5 Screen Orientation Enum + Subtitle Mode 2025-01-13 22:30:57 -05:00
Simon Caron
5703279b46 Merge develop 2025-01-13 21:18:37 -05:00
lostb1t
4022ccb213 feat: Custom homescreen support (#424) 2025-01-13 19:48:19 +01:00
Fredrik Burmester
3a836462f5 Merge pull request #422 from simoncaron/feat/hide-log-page-title
fix: Remove Page Path from Log Page Header
2025-01-13 17:58:39 +01:00
herrrta
8a5f24002f fix: unauthorized plugin access & null default values 2025-01-13 08:30:11 -05:00
retardgerman
c30f9860ee fix: fixed syntax errors 2025-01-13 12:31:23 +01:00
sarendsen
94c170e3d2 chore: some linting 2025-01-13 10:32:03 +01:00
Simon Caron
cd8aba32d8 Jellyseerr 2025-01-13 00:03:41 -05:00
Simon Caron
15f3ddf612 fix: Remove Page Path from Log Page 2025-01-12 23:00:12 -05:00
Simon Caron
90f20f6e46 Shorter messages 2025-01-12 21:34:08 -05:00
Simon Caron
ea1f45bbaf More settings + language component spacing 2025-01-12 21:30:57 -05:00
Simon Caron
7e62c9bc9a Merge branch 'develop' into feat/i18n 2025-01-12 19:49:58 -05:00
herrrta
23f9e9dfae fix: Override default settings with plugin unlocked default settings
- This sets the defaults on login and allows users to still change them
2025-01-12 19:24:01 -05:00
Simon Caron
580e12b605 Alert 2025-01-12 19:04:51 -05:00
Fredrik Burmester
ff4c5f28af chore 2025-01-12 14:11:09 +01:00
Fredrik Burmester
1b931ea348 Merge pull request #419 from streamyfin/fix/remove-music
fix: remove everything related to music
2025-01-12 14:07:59 +01:00
Fredrik Burmester
49c0437f81 fix: change opacity on press 2025-01-12 14:04:12 +01:00
Fredrik Burmester
d81ae94ce8 fix: add version to issue template 2025-01-12 13:41:33 +01:00
Fredrik Burmester
7c77c70024 chore: remove everything related to music 2025-01-12 13:40:01 +01:00
retardgerman
b28c4a56f3 fix: add new Releases to dropdown 2025-01-12 13:39:43 +01:00
Fredrik Burmester
2495a318eb Merge pull request #394 from Ryan0204/enhancement/autohidecontrol
enhancement: auto hide control after 5 seconds
2025-01-12 10:16:55 +01:00
Fredrik Burmester
7832ea4d0a chore: deps 2025-01-12 10:10:18 +01:00
Fredrik Burmester
4a0a51ef1d chore: refactor 2025-01-12 10:07:49 +01:00
Fredrik Burmester
8cc551d906 Merge pull request #416 from streamyfin/feat/server-discovery
feat: server discovery during login
2025-01-12 09:37:33 +01:00
Fredrik Burmester
c8da365a00 fix: issues listed in pr 2025-01-12 09:36:23 +01:00
Fredrik Burmester
74b7cbc530 Merge pull request #417 from whoopsi-daisy/patch-1
Update README.md
2025-01-12 09:33:38 +01:00
𝐂𝐡𝐫𝐢𝐬
a14063a736 Update README.md
Adjusted the Jellyseerr screenshot height to match the others and corrected a typo, along with rephrasing a sentence for clarity
2025-01-12 00:52:17 +08:00
Fredrik Burmester
a3307a90a3 feat: server discovery during login 2025-01-11 11:21:36 +01:00
Fredrik Burmester
a2145fd7e8 chore: update deps 2025-01-11 10:20:20 +01:00
Fredrik Burmester
cab5e4d980 chore: rename var 2025-01-11 10:10:00 +01:00
Fredrik Burmester
ab603e6997 feat: add centralised plugin info 2025-01-11 10:09:53 +01:00
ryan0204
957348fe19 prevent opening control when user swipe on screen 2025-01-11 16:41:41 +08:00
herrrta
444bd040b0 Merge pull request #402 from streamyfin/feat/401
Streamyfin Plugin App Management solution
2025-01-11 00:20:35 -05:00
herrrta
d0ae63235d feat: [StreamyfinPlugin] Library Options settings 2025-01-11 00:16:26 -05:00
herrrta
1727125ea7 feat: [StreamyfinPlugin] Popular Plugin settings 2025-01-11 00:16:25 -05:00
herrrta
dc498d62d8 feat: [StreamyfinPlugin] Other settings 2025-01-11 00:16:25 -05:00
herrrta
455bf08213 feat: [StreamyfinPlugin] Subtitle Toggles settings
- Used stepper & dropdown components to simplify page
2025-01-11 00:16:20 -05:00
herrrta
0f974ef2a3 feat: [StreamyfinPlugin] Audio Toggles settings 2025-01-10 23:26:53 -05:00
herrrta
2d9aaccfe0 feat: [StreamyfinPlugin] Media Toggles settings 2025-01-10 23:26:44 -05:00
herrrta
2c6823eb53 feat: [StreamyfinPlugin] Jellyseerr, Search Engine, & Download settings
- Added DisabledSetting.tsx component
- Added DownloadMethod enum
- cleanup
2025-01-10 23:26:32 -05:00
herrrta
9dfcc01f17 chore 2025-01-10 20:39:32 -05:00
ryan0204
0ebacd4bd3 Auto hide control after 5 seconds 2025-01-08 11:29:49 +08:00
Simon Caron
14c8c1aaed Fix some missing fields 2025-01-07 22:26:09 -05:00
Simon Caron
2da774272d Merge branch 'develop' into feat/i18n 2025-01-07 20:38:59 -05:00
Simon Caron
480abb216d fixes 2025-01-05 16:07:55 -05:00
Simon Caron
249109a94e livetv 2025-01-05 16:03:19 -05:00
Simon Caron
eb7fa93f9b remove dupe 2025-01-05 15:26:48 -05:00
Simon Caron
e8fd322d30 Merge branch 'master' into feat/i18n 2025-01-05 15:06:44 -05:00
Simon Caron
53ea1cc899 More Translations 2025-01-04 16:41:54 -05:00
Simon Caron
459ca3245b Rename card field 2025-01-04 15:39:04 -05:00
Simon Caron
0d1fb87284 Fix Language Selector Setting Component 2025-01-04 15:26:24 -05:00
Simon Caron
495742c52c Merge branch 'master' into feat/i18n 2025-01-04 14:57:45 -05:00
Simon Caron
894305e126 Item Card Fields 2025-01-04 14:49:56 -05:00
Simon Caron
ed993d07ce Types 2025-01-03 16:33:51 -05:00
Simon Caron
dc9008f31c Merge branch 'master' into feat/i18n 2025-01-03 15:23:17 -05:00
Simon Caron
e23387a384 Library headers, filters and favorites 2025-01-01 21:57:46 -05:00
Simon Caron
bb141cad57 Merge branch 'master' into feat/i18n 2025-01-01 21:32:24 -05:00
Simon Caron
e833b4bc68 Alert and Toasts 2025-01-01 21:31:04 -05:00
Simon Caron
34fc26ed18 Quick connect alerts 2025-01-01 20:29:39 -05:00
Fredrik Burmester
40b8410390 feat: enable manually setting language in settings 2025-01-01 11:25:02 +01:00
Simon Caron
723233381c Settings Fields V 2024-12-31 16:09:12 -05:00
Simon Caron
602de34824 Settings fields 2024-12-31 15:31:36 -05:00
Simon Caron
9b1f2a98e5 Update translation key casing to snake_case 2024-12-31 14:43:40 -05:00
Simon Caron
946de97580 Remove LanguageSwitcher 2024-12-31 14:39:04 -05:00
Simon Caron
f2eadabf6a bump libs versions 2024-12-31 13:52:58 -05:00
Simon Caron
373d83a0d5 Basic downloads stack translation 2024-12-31 13:34:32 -05:00
Simon Caron
2c0ba18b49 Clean up const declarations 2024-12-31 13:10:46 -05:00
Simon Caron
3e8e8e1163 Merge branch 'master' into feat/i18n 2024-12-31 12:24:28 -05:00
Simon Caron
fe9c73a8f0 Library Translation 2024-12-30 21:52:34 -05:00
Simon Caron
4f62391027 Add fr, search translation, fix login title 2024-12-30 21:38:42 -05:00
Simon Caron
53b5fdda87 fix import 2024-12-30 21:13:52 -05:00
Simon Caron
c0b71eb73d Revert login message 2024-12-30 21:03:02 -05:00
Simon Caron
9b4590c876 Update Current Translated Messages with UI Changes 2024-12-30 20:06:56 -05:00
Simon Caron
4b18bad3bc Merge branch 'master' into feat/i18n 2024-12-30 16:45:41 -05:00
Fredrik Burmester
752cb1cdc6 wip 2024-08-18 17:10:31 +02:00
126 changed files with 3047 additions and 2719 deletions

View File

@@ -44,6 +44,8 @@ body:
description: What version of Streamyfin are you running?
options:
- 0.25.0
- 0.24.0
- 0.23.0
- 0.22.0
- 0.21.0
- older

View File

@@ -8,7 +8,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
<img width=150 src="./assets/images/jellyseerr.PNG"/>
<img width=159 src="./assets/images/jellyseerr.PNG"/>
</div>
## 🌟 Features
@@ -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

View File

@@ -105,6 +105,7 @@
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
],
"expo-localization",
"expo-asset",
[
"react-native-edge-to-edge",
@@ -112,7 +113,8 @@
],
["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withGoogleCastActivity.js"]
["./plugins/withGoogleCastActivity.js"],
["./plugins/withTrustLocalCerts.js"]
],
"experiments": {
"typedRoutes": true

View File

@@ -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 (
<Stack>
<Stack.Screen
@@ -9,7 +11,7 @@ export default function CustomMenuLayout() {
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Custom Links",
headerTitle: t("tabs.custom_links"),
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,

View File

@@ -7,6 +7,7 @@ import { ListItem } from "@/components/list/ListItem";
import * as WebBrowser from "expo-web-browser";
import Ionicons from "@expo/vector-icons/Ionicons";
import { Text } from "@/components/common/Text";
import { useTranslation } from "react-i18next";
export interface MenuLink {
name: string;
@@ -18,6 +19,7 @@ export default function menuLinks() {
const [api] = useAtom(apiAtom);
const insets = useSafeAreaInsets();
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
const { t } = useTranslation();
const getMenuLinks = useCallback(async () => {
try {
@@ -67,7 +69,7 @@ export default function menuLinks() {
)}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No links</Text>
<Text className="font-bold text-xl text-neutral-500">{t("custom_links.no_links")}</Text>
</View>
}
/>

View File

@@ -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 (
<Stack>
<Stack.Screen
@@ -10,7 +12,7 @@ export default function SearchLayout() {
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Favorites",
headerTitle: t("tabs.favorites"),
headerLargeStyle: {
backgroundColor: "black",
},

View File

@@ -4,9 +4,11 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageSta
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native";
import { useTranslation } from "react-i18next";
export default function IndexLayout() {
const router = useRouter();
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
@@ -14,7 +16,7 @@ export default function IndexLayout() {
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Home",
headerTitle: t("tabs.home"),
headerBlurEffect: "prominent",
headerLargeStyle: {
backgroundColor: "black",
@@ -38,19 +40,19 @@ export default function IndexLayout() {
<Stack.Screen
name="downloads/index"
options={{
title: "Downloads",
title: t("home.downloads.downloads_title"),
}}
/>
<Stack.Screen
name="downloads/[seriesId]"
options={{
title: "TV-Series",
title: t("home.downloads.tvseries"),
}}
/>
<Stack.Screen
name="settings"
options={{
title: "Settings",
title: t("home.settings.settings_title"),
}}
/>
<Stack.Screen
@@ -72,13 +74,13 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name="settings/popular-lists/page"
name="settings/hide-libraries/page"
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/hide-libraries/page"
name="settings/logs/page"
options={{
title: "",
}}

View File

@@ -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() {
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
{settings?.downloadMethod === DownloadMethod.Remux && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
<Text className="text-xs opacity-70 text-red-600">
Queue and active downloads will be lost on app restart
{t("home.downloads.queue_hint")}
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q, index) => (
@@ -133,7 +136,7 @@ export default function page() {
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
<Text className="opacity-50">{t("home.downloads.no_items_in_queue")}</Text>
)}
</View>
)}
@@ -144,7 +147,7 @@ export default function page() {
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">Movies</Text>
<Text className="text-lg font-bold">{t("home.downloads.movies")}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
@@ -163,7 +166,7 @@ export default function page() {
{groupedBySeries.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">TV-Series</Text>
<Text className="text-lg font-bold">{t("home.downloads.tvseries")}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">
{groupedBySeries?.length}
@@ -189,7 +192,7 @@ export default function page() {
)}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text>
<Text className="opacity-50">{t("home.downloads.no_downloaded_items")}</Text>
</View>
)}
</View>
@@ -214,13 +217,13 @@ export default function page() {
<BottomSheetView>
<View className="p-4 space-y-4 mb-4">
<Button color="purple" onPress={deleteMovies}>
Delete all Movies
{t("home.downloads.delete_all_movies_button")}
</Button>
<Button color="purple" onPress={deleteShows}>
Delete all TV-Series
{t("home.downloads.delete_all_tvseries_button")}
</Button>
<Button color="red" onPress={deleteAllMedia}>
Delete all
{t("home.downloads.delete_all_button")}
</Button>
</View>
</BottomSheetView>
@@ -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(),
},

View File

@@ -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 {
@@ -27,6 +27,7 @@ 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<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
@@ -110,6 +119,7 @@ export default function index() {
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
return () => {
unsubscribe();
};
@@ -141,29 +151,6 @@ export default function index() {
[data, settings?.hiddenLibraries]
);
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 collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
@@ -177,6 +164,7 @@ export default function index() {
const refetch = useCallback(async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
}, []);
@@ -211,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 (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">No Internet</Text>
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
<Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content.
{t("home.no_internet_message")}
</Text>
<View className="mt-4">
<Button
@@ -329,7 +363,7 @@ export default function index() {
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
Go to downloads
{t("home.go_to_downloads")}
</Button>
<Button
color="black"
@@ -355,17 +389,15 @@ export default function index() {
);
}
if (e1 || e2)
if (e1)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
<Text className="text-center opacity-70">
Something went wrong.{"\n"}Please log out and in again.
</Text>
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70">{t("home.error_message")}</Text>
</View>
);
if (l1 || l2)
if (l1)
return (
<View className="justify-center items-center h-full">
<Loader />

View File

@@ -5,10 +5,12 @@ import { Feather, Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useFocusEffect, useRouter } from "expo-router";
import { useCallback } from "react";
import { TouchableOpacity, View } from "react-native";
import {useTranslation } from "react-i18next";
import { Linking, TouchableOpacity, View } from "react-native";
export default function page() {
const router = useRouter();
const { t } = useTranslation();
useFocusEffect(
useCallback(() => {
@@ -17,21 +19,20 @@ export default function page() {
);
return (
<View className="bg-neutral-900 h-full py-32 px-4 space-y-4">
<View className="bg-neutral-900 h-full py-16 px-4 space-y-8">
<View>
<Text className="text-3xl font-bold text-center mb-2">
Welcome to Streamyfin
{t("home.intro.welcome_to_streamyfin")}
</Text>
<Text className="text-center">
A free and open source client for Jellyfin.
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
</Text>
</View>
<View>
<Text className="text-lg font-bold">Features</Text>
<Text className="text-lg font-bold">{t("home.intro.features_title")}</Text>
<Text className="text-xs">
Streamyfin has a bunch of features and integrates with a wide array of
software which you can find in the settings menu, these include:
{t("home.intro.features_description")}
</Text>
<View className="flex flex-row items-center mt-4">
<Image
@@ -44,8 +45,7 @@ export default function page() {
<View className="shrink ml-2">
<Text className="font-bold mb-1">Jellyseerr</Text>
<Text className="shrink text-xs">
Connect to your Jellyseerr instance and request movies directly in
the app.
{t("home.intro.jellyseerr_feature_description")}
</Text>
</View>
</View>
@@ -60,11 +60,9 @@ export default function page() {
<Ionicons name="cloud-download-outline" size={32} color="white" />
</View>
<View className="shrink ml-2">
<Text className="font-bold mb-1">Downloads</Text>
<Text className="font-bold mb-1">{t("home.intro.downloads_feature_title")}</Text>
<Text className="shrink text-xs">
Download movies and tv-shows to view offline. Use either the
default method or install the optimize server to download files in
the background.
{t("home.intro.downloads_feature_description")}
</Text>
</View>
</View>
@@ -81,29 +79,57 @@ export default function page() {
<View className="shrink ml-2">
<Text className="font-bold mb-1">Chromecast</Text>
<Text className="shrink text-xs">
Cast movies and tv-shows to your Chromecast devices.
{t("home.intro.chromecast_feature_description")}
</Text>
</View>
</View>
<View className="flex flex-row items-center mt-4">
<View
style={{
width: 50,
height: 50,
}}
className="flex items-center justify-center"
>
<Feather name="settings" size={28} color={"white"} />
</View>
<View className="shrink ml-2">
<Text className="font-bold mb-1">{t("home.intro.centralised_settings_plugin_title")}</Text>
<Text className="shrink text-xs">
{t("home.intro.centralised_settings_plugin_description")}{" "}
<Text
className="text-purple-600"
onPress={() => {
Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin"
);
}}
>
{t("home.intro.read_more")}
</Text>
</Text>
</View>
</View>
</View>
<Button
onPress={() => {
router.back();
}}
className="mt-4"
>
Done
</Button>
<TouchableOpacity
onPress={() => {
router.back();
router.push("/settings");
}}
className="mt-4"
>
<Text className="text-purple-600 text-center">Go to settings</Text>
</TouchableOpacity>
<View>
<Button
onPress={() => {
router.back();
}}
className="mt-4"
>
{t("home.intro.done_button")}
</Button>
<TouchableOpacity
onPress={() => {
router.back();
router.push("/settings");
}}
className="mt-4"
>
<Text className="text-purple-600 text-center">{t("home.intro.go_to_settings_button")}</Text>
</TouchableOpacity>
</View>
</View>
);
}

View File

@@ -10,12 +10,14 @@ 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 { 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";
@@ -40,7 +42,7 @@ export default function settings() {
logout();
}}
>
<Text className="text-red-600">Log out</Text>
<Text className="text-red-600">{t("home.settings.log_out_button")}</Text>
</TouchableOpacity>
),
});
@@ -68,33 +70,35 @@ export default function settings() {
<PluginSettings />
<AppLanguageSelector/>
<ListGroup title={"Intro"}>
<ListItem
onPress={() => {
router.push("/intro/page");
}}
title={"Show intro"}
title={t("home.settings.intro.show_intro")}
/>
<ListItem
textColor="red"
onPress={() => {
storage.set("hasShownIntro", false);
}}
title={"Reset intro"}
title={t("home.settings.intro.reset_intro")}
/>
</ListGroup>
<View className="mb-4">
<ListGroup title={"Logs"}>
<ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem
onPress={() => router.push("/settings/logs/page")}
showArrow
title={"Logs"}
title={t("home.settings.logs.logs_title")}
/>
<ListItem
textColor="red"
onPress={onClearLogsClicked}
title={"Delete All Logs"}
title={t("home.settings.logs.delete_all_logs")}
/>
</ListGroup>
</View>

View File

@@ -8,12 +8,16 @@ 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] = useSettings();
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 () => {
@@ -35,7 +39,10 @@ export default function page() {
);
return (
<View className="px-4">
<DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true}
className="px-4"
>
<ListGroup>
{data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
@@ -53,9 +60,8 @@ export default function page() {
))}
</ListGroup>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Select the libraries you want to hide from the Library tab and home page
sections.
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</View>
</DisabledSetting>
);
}

View File

@@ -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<string>(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 ? (
// <ActivityIndicator size={"small"} color={"white"} />
// ) : (
// <TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
// <Text className="text-blue-500">Save</Text>
// </TouchableOpacity>
// ),
// });
// }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
const [settings, updateSettings, pluginSettings] = useSettings();
return (
<View className="p-4">
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className="p-4"
>
<JellyseerrSettings />
</View>
</DisabledSetting>
);
}

View File

@@ -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 (
<ScrollView className="p-4">
@@ -25,7 +27,7 @@ export default function page() {
</View>
))}
{logs?.length === 0 && (
<Text className="opacity-50">No logs available</Text>
<Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
)}
</View>
</ScrollView>

View File

@@ -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<string>(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: () => (
<TouchableOpacity onPress={() => onSave(value)}>
<Text className="text-blue-500">Save</Text>
</TouchableOpacity>
),
});
if (!pluginSettings?.marlinServerUrl?.locked) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)}>
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
</TouchableOpacity>
),
});
}
}, [navigation, value]);
if (!settings) return null;
return (
<View className="px-4">
<DisabledSetting
disabled={disabled}
className="px-4"
>
<ListGroup>
<ListItem
title={"Enable Marlin Search"}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
<DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
<ListItem
title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
>
<Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup>
<View
className={`mt-2 ${
settings.searchEngine === "Marlin" ? "" : "opacity-50"
}`}
<DisabledSetting
disabled={pluginSettings?.marlinServerUrl?.locked === true}
showText={!pluginSettings?.searchEngine?.locked}
className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
>
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
<View
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
>
<Text className="mr-4">URL</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className="text-white"
placeholder="http(s)://domain.org:port"
value={value}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setValue(text)}
/>
</View>
<View
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
>
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className="text-white"
placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
value={value}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setValue(text)}
/>
</View>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Enter the URL for the Marlin server. The URL should include http or
https and optionally the port.{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
Read more about Marlin.
</Text>
</DisabledSetting>
<Text className="px-4 text-xs text-neutral-500 mt-1">
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text>
</View>
</View>
</Text>
</DisabledSetting>
);
}

View File

@@ -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<string>(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 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
<Text className="text-blue-500">Save</Text>
</TouchableOpacity>
),
});
if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
navigation.setOptions({
title: t("home.settings.downloads.optimized_server"),
headerRight: () =>
saveMutation.isPending ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
</TouchableOpacity>
),
});
}
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return (
<View className="p-4">
<DisabledSetting
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
className="p-4"
>
<OptimizedServerForm
value={optimizedVersionsServerUrl}
onChangeValue={setOptimizedVersionsServerUrl}
/>
</View>
</DisabledSetting>
);
}

View File

@@ -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 (
<View className="px-4 pt-4">
<ListGroup title={"Enable plugin"} className="">
<ListItem
title={"Enable Popular Lists"}
onPress={() => {
updateSettings({ usePopularPlugin: true });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
value={settings.usePopularPlugin}
onValueChange={(value) => {
updateSettings({ usePopularPlugin: value });
}}
/>
</ListItem>
</ListGroup>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Popular Lists is a plugin that enables you to show custom Jellyfin lists
on the Streamyfin home page.{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
Read more about Popular Lists.
</Text>
</Text>
{settings.usePopularPlugin && (
<>
{!isLoadingMediaListCollections ? (
<>
{mediaListCollections?.length === 0 ? (
<Text className="text-xs opacity-50 p-4">
No collections found. Add some in Jellyfin.
</Text>
) : (
<>
<ListGroup title="Media List Collections" className="mt-4">
{mediaListCollections?.map((mlc) => (
<ListItem key={mlc.Id} title={mlc.Name}>
<Switch
value={settings.mediaListCollectionIds?.includes(
mlc.Id!
)}
onValueChange={(value) => {
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!,
],
});
}}
/>
</ListItem>
))}
</ListGroup>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Select the lists you want displayed on the home screen.
</Text>
</>
)}
</>
) : (
<Loader />
)}
</>
)}
</View>
);
}

View File

@@ -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 = () => {
</View>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
Appeared In
{t("item_card.appeared_in")}
</Text>
<InfiniteHorizontalScroll
height={247}

View File

@@ -1,128 +0,0 @@
import { Chromecast } from "@/components/Chromecast";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { SongsList } from "@/components/music/SongsList";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import ArtistPoster from "@/components/posters/ArtistPoster";
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 { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const searchParams = useLocalSearchParams();
const { collectionId, artistId, albumId } = searchParams as {
collectionId: string;
artistId: string;
albumId: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View className="">
<Chromecast />
</View>
),
});
});
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 (
<ParallaxScrollView
headerHeight={400}
headerImage={
<ItemImage
variant={"Primary"}
item={album}
style={{
width: "100%",
height: "100%",
}}
/>
}
>
<View className="px-4 mb-8">
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text>
<Text className="text-neutral-500">
{songs?.TotalRecordCount} songs
</Text>
</View>
<View className="px-4">
<SongsList
albumId={albumId}
songs={songs?.Items}
collectionId={collectionId}
artistId={artistId}
/>
</View>
</ParallaxScrollView>
);
}

View File

@@ -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<number>(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 (
<ParallaxScrollView
headerHeight={400}
headerImage={
<ItemImage
variant={"Primary"}
item={artist}
style={{
width: "100%",
height: "100%",
}}
/>
}
>
<View className="px-4 mb-8">
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text>
<Text className="text-neutral-500">
{albums.TotalRecordCount} albums
</Text>
</View>
<View className="flex flex-row flex-wrap justify-between px-4">
{albums.Items.map((item, idx) => (
<TouchableItemRouter
item={item}
style={{ width: "30%", marginBottom: 20 }}
key={idx}
>
<View className="flex flex-col gap-y-2">
<ArtistPoster item={item} />
<Text numberOfLines={2}>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableItemRouter>
))}
</View>
</ParallaxScrollView>
);
}

View File

@@ -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<number>(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 (
<FlatList
contentContainerStyle={{
padding: 16,
paddingBottom: 140,
}}
ListHeaderComponent={
<View className="mb-4">
<Text className="font-bold text-3xl mb-2">Artists</Text>
</View>
}
nestedScrollEnabled
data={data.Items}
numColumns={3}
columnWrapperStyle={{
justifyContent: "space-between",
}}
renderItem={({ item, index }) => (
<TouchableItemRouter
style={{
maxWidth: "30%",
width: "100%",
}}
key={index}
item={item}
>
<View className="flex flex-col gap-y-2">
{collection?.CollectionType === "movies" && (
<MoviePoster item={item} />
)}
{collection?.CollectionType === "music" && (
<ArtistPoster item={item} />
)}
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableItemRouter>
)}
keyExtractor={(item) => item.Id || ""}
/>
);
}

View File

@@ -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 = () => {
<FlashList
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text>
<Text className="font-bold text-xl text-neutral-500">{t("search.no_results")}</Text>
</View>
}
extraData={[

View File

@@ -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 (
<View className="flex flex-col items-center justify-center h-screen w-screen">
<Text>Could not load item</Text>
<Text>{t("item_card.could_not_load_item")}</Text>
</View>
);

View File

@@ -17,6 +17,7 @@ import {
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 { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
@@ -39,6 +40,8 @@ import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/request
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
const { mediaTitle, releaseYear, posterSrc, ...result } =
params as unknown as {
mediaTitle: string;
@@ -214,7 +217,7 @@ const Page: React.FC = () => {
<Button loading={true} disabled={true} color="purple"></Button>
) : canRequest ? (
<Button color="purple" onPress={request}>
Request
{t("jellyseerr.request_button")}
</Button>
) : (
<Button
@@ -229,7 +232,7 @@ const Page: React.FC = () => {
borderStyle: "solid",
}}
>
Report issue
{t("jellyseerr.report_issue_button")}
</Button>
)}
<OverviewText text={result.overview} className="mt-4" />
@@ -281,7 +284,7 @@ const Page: React.FC = () => {
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">
Whats wrong?
{t("jellyseerr.whats_wrong")}
</Text>
</View>
<View className="flex flex-col space-y-2 items-start">
@@ -290,13 +293,13 @@ const Page: React.FC = () => {
<DropdownMenu.Trigger>
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">
Issue Type
{t("jellyseerr.issue_type")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: "Select an issue"}
: t("jellyseerr.select_an_issue")}
</Text>
</TouchableOpacity>
</View>
@@ -310,7 +313,7 @@ const Page: React.FC = () => {
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>Types</DropdownMenu.Label>
<DropdownMenu.Label>{t("jellyseerr.types")}</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], idx) => (
@@ -335,7 +338,7 @@ const Page: React.FC = () => {
maxLength={254}
style={{ color: "white" }}
clearButtonMode="always"
placeholder="(optional) Describe the issue..."
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor="#9CA3AF"
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
@@ -345,7 +348,7 @@ const Page: React.FC = () => {
</View>
</View>
<Button className="mt-auto" onPress={submitIssue} color="purple">
Submit
{t("jellyseerr.submit_button")}
</Button>
</View>
</BottomSheetView>

View File

@@ -13,9 +13,12 @@ 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 };
@@ -58,7 +61,7 @@ export default function page() {
<ParallaxSlideShow
data={castedRoles}
images={backdrops}
listHeader="Appearances"
listHeader={t("jellyseerr.appearances")}
keyExtractor={(item) => item.id.toString()}
logo={
<Image
@@ -85,7 +88,7 @@ export default function page() {
{data?.details?.name}
</Text>
<Text className="opacity-50">
Born{" "}
{t("jellyseerr.born")}{" "}
{new Date(data?.details?.birthday!!).toLocaleDateString(
`${locale}-${region}`,
{

View File

@@ -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<PageButtonsProps> = ({
onNextPage,
isNextDisabled,
}) => {
const { t } = useTranslation();
return (
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
<TouchableOpacity
@@ -194,7 +196,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
currentPage === 1 ? "text-gray-500" : "text-white"
}`}
>
Previous
{t("live_tv.previous")}
</Text>
</TouchableOpacity>
<Text className="text-white">Page {currentPage}</Text>
@@ -206,7 +208,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
<Text
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
>
Next
{t("live_tv.next")}
</Text>
<Ionicons
name="chevron-forward"

View File

@@ -7,12 +7,15 @@ import { useAtom } from "jotai";
import React from "react";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
return (
<ScrollView
nestedScrollEnabled
@@ -28,7 +31,7 @@ export default function page() {
<View className="flex flex-col space-y-2">
<ScrollingCollectionList
queryKey={["livetv", "recommended"]}
title={"On now"}
title={t("live_tv.on_now")}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getRecommendedPrograms({
@@ -46,7 +49,7 @@ export default function page() {
/>
<ScrollingCollectionList
queryKey={["livetv", "shows"]}
title={"Shows"}
title={t("live_tv.shows")}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -68,7 +71,7 @@ export default function page() {
/>
<ScrollingCollectionList
queryKey={["livetv", "movies"]}
title={"Movies"}
title={t("live_tv.movies")}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -86,7 +89,7 @@ export default function page() {
/>
<ScrollingCollectionList
queryKey={["livetv", "sports"]}
title={"Sports"}
title={t("live_tv.sports")}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -104,7 +107,7 @@ export default function page() {
/>
<ScrollingCollectionList
queryKey={["livetv", "kids"]}
title={"For Kids"}
title={t("live_tv.for_kids")}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -122,7 +125,7 @@ export default function page() {
/>
<ScrollingCollectionList
queryKey={["livetv", "news"]}
title={"News"}
title={t("live_tv.news")}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({

View File

@@ -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 (
<View className="flex items-center justify-center h-full -mt-12">
<Text>Coming soon</Text>
<Text>{t("live_tv.coming_soon")}</Text>
</View>
);
}

View File

@@ -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 = () => {
<AddToFavorites item={item} type="series" />
<DownloadItems
size="large"
title="Download Series"
title={t("item_card.download.download_series")}
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name="download" size={22} color="white" />

View File

@@ -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 (
<View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">No items found</Text>
<Text className="text-lg text-neutral-500">{t("library.no_items_found")}</Text>
</View>
);
@@ -445,7 +446,7 @@ const Page = () => {
key={orientation}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text>
<Text className="font-bold text-xl text-neutral-500">{t("library.no_results")}</Text>
</View>
}
contentInsetAdjustmentBehavior="automatic"

View File

@@ -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 &&
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Ionicons
@@ -42,11 +46,11 @@ export default function IndexLayout() {
side={"bottom"}
sideOffset={10}
>
<DropdownMenu.Label>Display</DropdownMenu.Label>
<DropdownMenu.Label>{t("library.options.display")}</DropdownMenu.Label>
<DropdownMenu.Group key="display-group">
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
Display
{t("library.options.display")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
@@ -69,7 +73,7 @@ export default function IndexLayout() {
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-1">
Row
{t("library.options.row")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
@@ -86,14 +90,14 @@ export default function IndexLayout() {
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-2">
List
{t("library.options.list")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
Image style
{t("library.options.image_style")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
@@ -116,7 +120,7 @@ export default function IndexLayout() {
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="poster-title">
Poster
{t("library.options.poster")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
@@ -133,7 +137,7 @@ export default function IndexLayout() {
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="cover-title">
Cover
{t("library.options.cover")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
@@ -157,7 +161,7 @@ export default function IndexLayout() {
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-titles-title">
Show titles
{t("library.options.show_titles")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
@@ -174,7 +178,7 @@ export default function IndexLayout() {
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-stats-title">
Show stats
{t("library.options.show_stats")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.Group>

View File

@@ -13,6 +13,7 @@ import { useAtom } from "jotai";
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,6 +21,8 @@ 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 () => {
@@ -33,7 +36,11 @@ export default function index() {
});
const libraries = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
() =>
data
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "music")
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries]
);
@@ -66,7 +73,7 @@ export default function index() {
if (!libraries)
return (
<View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">No libraries found</Text>
<Text className="text-lg text-neutral-500">{t("library.no_libraries_found")}</Text>
</View>
);

View File

@@ -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 (
<Stack>
<Stack.Screen
@@ -13,7 +15,7 @@ export default function SearchLayout() {
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Search",
headerTitle: t("tabs.search"),
headerLargeStyle: {
backgroundColor: "black",
},

View File

@@ -5,7 +5,6 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText";
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
@@ -32,6 +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 { useTranslation } from "react-i18next";
type SearchType = "Library" | "Discover";
@@ -48,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<string> };
const [searchType, setSearchType] = useState<SearchType>("Library");
@@ -123,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);
@@ -184,52 +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
);
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
}, [episodes, movies, series, collections, actors]);
const loading = useMemo(() => {
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
return l1 || l2 || l3 || l7 || l8;
}, [l1, l2, l3, l7, l8]);
return (
<>
@@ -248,7 +217,7 @@ export default function search() {
autoCorrect={false}
returnKeyType="done"
keyboardType="web-search"
placeholder="Search here..."
placeholder={t("search.search_here")}
value={search}
onChangeText={(text) => setSearch(text)}
/>
@@ -258,7 +227,7 @@ export default function search() {
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<TouchableOpacity onPress={() => setSearchType("Library")}>
<Tag
text="Library"
text={t("search.library")}
textClass="p-1"
className={
searchType === "Library" ? "bg-purple-600" : undefined
@@ -267,7 +236,7 @@ export default function search() {
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text="Discover"
text={t("search.discover")}
textClass="p-1"
className={
searchType === "Discover" ? "bg-purple-600" : undefined
@@ -284,7 +253,7 @@ export default function search() {
{searchType === "Library" ? (
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header="Movies"
header={t("search.movies")}
ids={movies?.map((m) => m.Id!)}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
@@ -304,7 +273,7 @@ export default function search() {
/>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
header="Series"
header={t("search.series")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
@@ -323,7 +292,7 @@ export default function search() {
/>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
header="Episodes"
header={t("search.episodes")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
@@ -337,7 +306,7 @@ export default function search() {
/>
<SearchItemWrapper
ids={collections?.map((m) => m.Id!)}
header="Collections"
header={t("search.collections")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
@@ -353,7 +322,7 @@ export default function search() {
/>
<SearchItemWrapper
ids={actors?.map((m) => m.Id!)}
header="Actors"
header={t("search.actors")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
@@ -365,48 +334,6 @@ export default function search() {
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={artists?.map((m) => m.Id!)}
header="Artists"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={albums?.map((m) => m.Id!)}
header="Albums"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={songs?.map((m) => m.Id!)}
header="Songs"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
</View>
) : (
<JellyserrIndexPage searchQuery={debouncedSearch} />
@@ -417,7 +344,7 @@ export default function search() {
{!loading && noResults && debouncedSearch.length > 0 ? (
<View>
<Text className="text-center text-lg font-bold mt-4">
No results found for
{t("search.no_results_found_for")}
</Text>
<Text className="text-xs text-purple-600 text-center">
"{debouncedSearch}"

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useRef } from "react";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
@@ -30,6 +31,7 @@ export const NativeTabs = withLayoutContext<
export default function TabLayout() {
const [settings] = useSettings();
const { t } = useTranslation();
const router = useRouter();
useFocusEffect(
@@ -61,7 +63,7 @@ export default function TabLayout() {
<NativeTabs.Screen
name="(home)"
options={{
title: "Home",
title: t("tabs.home"),
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
@@ -75,7 +77,7 @@ export default function TabLayout() {
<NativeTabs.Screen
name="(search)"
options={{
title: "Search",
title: t("tabs.search"),
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
@@ -89,7 +91,7 @@ export default function TabLayout() {
<NativeTabs.Screen
name="(favorites)"
options={{
title: "Favorites",
title: t("tabs.favorites"),
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
@@ -105,7 +107,7 @@ export default function TabLayout() {
<NativeTabs.Screen
name="(libraries)"
options={{
title: "Library",
title: t("tabs.library"),
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
@@ -119,7 +121,7 @@ export default function TabLayout() {
<NativeTabs.Screen
name="(custom-links)"
options={{
title: "Custom Links",
title: t("tabs.custom_links"),
// @ts-expect-error
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarIcon:

View File

@@ -25,15 +25,6 @@ export default function Layout() {
animation: "fade",
}}
/>
<Stack.Screen
name="music-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack>
</>
);

View File

@@ -48,12 +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<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
@@ -161,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;
}
@@ -426,7 +428,7 @@ export default function page() {
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
<Text className="text-white">{t("player.error")}</Text>
</View>
);
@@ -465,8 +467,8 @@ export default function page() {
onVideoError={(e) => {
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);
}}

View File

@@ -1,419 +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 { useHaptic } from "@/hooks/useHaptic";
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<VideoRef | null>(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 lightHapticFeedback = useHaptic("light");
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) => {
lightHapticFeedback();
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(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 (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
if (!item || !stream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
return (
<View
style={{
width: windowDimensions.width,
height: windowDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
<Image
source={poster}
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
/>
</View>
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full opacity-0"
>
{videoSource && (
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
setIsPlaying(state.isPlaying);
}}
/>
)}
</Pressable>
<Controls
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
enableTrickplay={false}
pause={pause}
play={play}
seek={seek}
isVlc={false}
stop={stop}
/>
</View>
);
}
export function usePoster(
item: BaseItemDto | null | undefined,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}
export function useVideoSource(
item: BaseItemDto | null | undefined,
api: Api | null,
poster: string | undefined,
url?: string | null
) {
const videoSource = useMemo(() => {
if (!item || !api || !url) {
return null;
}
const startPosition = item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: item?.AlbumArtist ?? undefined,
title: item?.Name || "Unknown",
description: item?.Overview ?? undefined,
imageUri: poster,
subtitle: item?.Album ?? undefined,
},
};
}, [item, api, poster]);
return videoSource;
}

View File

@@ -39,12 +39,14 @@ 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<VideoRef | null>(null);
const { t } = useTranslation();
const firstTime = useRef(true);
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -374,7 +376,7 @@ const Player = () => {
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
<Text className="text-white">{t("player.error")}</Text>
</View>
);
@@ -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 = () => {
/>
</>
) : (
<Text>No video source...</Text>
<Text>{t("player.no_video_source")}</Text>
)}
</View>
@@ -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,

View File

@@ -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 (
<>
<Stack.Screen options={{ title: "Oops!" }} />

View File

@@ -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 (
<JotaiProvider>
<Layout />
<I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</JotaiProvider>
);
}
@@ -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(() => {

View File

@@ -1,16 +1,12 @@
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import {
Ionicons,
MaterialCommunityIcons,
MaterialIcons,
} from "@expo/vector-icons";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
@@ -25,12 +21,11 @@ import {
} from "react-native";
import { z } from "zod";
import { t } from 'i18next';
const CredentialsSchema = z.object({
username: z.string().min(1, "Username is required"),
});
username: z.string().min(1, t("login.username_required")),});
const Login: React.FC = () => {
const Login: React.FC = () => {
const { setServer, login, removeServer, initiateQuickConnect } =
useJellyfin();
const [api] = useAtom(apiAtom);
@@ -84,7 +79,7 @@ const Login: React.FC = () => {
className="flex flex-row items-center"
>
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
<Text className="ml-2 text-purple-600">Change server</Text>
<Text className="ml-2 text-purple-600">{t("login.change_server")}</Text>
</TouchableOpacity>
) : null,
});
@@ -101,9 +96,9 @@ const Login: React.FC = () => {
}
} catch (error) {
if (error instanceof Error) {
Alert.alert("Connection failed", error.message);
Alert.alert(t("login.connection_failed"), error.message);
} else {
Alert.alert("Connection failed", "An unexpected error occurred");
Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
}
} finally {
setLoading(false);
@@ -167,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;
}
@@ -186,14 +180,14 @@ 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"));
}
};
@@ -207,22 +201,21 @@ const Login: React.FC = () => {
<View className="flex flex-col h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold -mb-2">
Log in
<>
{serverName ? (
<>
{" to "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : null}
</>
</Text>
<Text className="text-2xl font-bold -mb-2">
<>
{serverName ? (
<>
{t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : t("login.login_title")}
</>
</Text>
<Text className="text-xs text-neutral-400">
{api.basePath}
</Text>
<Input
placeholder="Username"
placeholder={t("login.username_placeholder")}
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
@@ -238,7 +231,7 @@ const Login: React.FC = () => {
/>
<Input
placeholder="Password"
placeholder={t("login.password_placeholder")}
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
@@ -257,7 +250,7 @@ const Login: React.FC = () => {
loading={loading}
className="flex-1 mr-2"
>
Log in
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
@@ -291,11 +284,11 @@ const Login: React.FC = () => {
/>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
Enter the URL to your Jellyfin server
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input
aria-label="Server URL"
placeholder="http(s)://your-server.com"
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
@@ -304,15 +297,23 @@ const Login: React.FC = () => {
textContentType="URL"
maxLength={500}
/>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="w-full grow"
>
Connect
{t("server.connect_button")}
</Button>
<JellyfinServerDiscovery
onServerSelect={(server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={(s) => {
handleConnect(s.address);

View File

@@ -1,3 +1,4 @@
export * from "./api";
export * from "./mmkv";
export * from "./number";
export * from "./string";

BIN
bun.lockb

Binary file not shown.

View File

@@ -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<typeof View> {
source?: MediaSourceInfo;
@@ -26,6 +27,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
[audioStreams, selected]
);
const { t } = useTranslation();
return (
<View
className="flex shrink"
@@ -36,7 +39,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
<Text className="opacity-50 mb-1 text-xs">{t("item_card.audio")}</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="" numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle}

View File

@@ -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;
@@ -63,6 +64,8 @@ export const BitrateSelector: React.FC<Props> = ({
);
}, []);
const { t } = useTranslation();
return (
<View
className="flex shrink"
@@ -74,7 +77,7 @@ export const BitrateSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
<Text className="opacity-50 mb-1 text-xs">{t("item_card.quality")}</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}

View File

@@ -49,6 +49,11 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
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(() => {

View File

@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { 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<DownloadProps> = ({
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<DownloadProps> = ({
[user]
);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === "optimized",
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings]
);
@@ -160,7 +162,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
);
}
} 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<DownloadProps> = ({
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<DownloadProps> = ({
{title}
</Text>
<Text className="text-neutral-300">
{subtitle || `Download ${itemsNotDownloaded.length} items`}
{subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})}
</Text>
</View>
<View className="flex flex-col space-y-2 w-full items-start">
@@ -368,13 +370,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
onPress={acceptDownloadOptions}
color="purple"
>
Download
{t("item_card.download.download_button")}
</Button>
<View className="opacity-70 text-center w-full flex items-center">
<Text className="text-xs">
{usingOptimizedServer
? "Using optimized server"
: "Using default method"}
? t("item_card.download.using_optimized_server")
: t("item_card.download.using_default_method")}
</Text>
</View>
</View>
@@ -391,7 +393,9 @@ export const DownloadSingleItem: React.FC<{
return (
<DownloadItems
size={size}
title="Download Episode"
title={item.Type == "Episode"
? t("item_card.download.download_episode")
: t("item_card.download.download_movie")}
subtitle={item.Name!}
items={[item]}
MissingDownloadIconComponent={() => (

View File

@@ -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<Props> = ({ source, ...props }) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
return (
<View className="px-4 mt-2 mb-4">
<Text className="text-lg font-bold mb-4">Video</Text>
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
<View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} />
</View>
<Text className="text-purple-600">More details</Text>
<Text className="text-purple-600">{t("item_card.more_details")}</Text>
</TouchableOpacity>
<BottomSheetModal
ref={bottomSheetModalRef}
@@ -52,14 +54,14 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
<BottomSheetScrollView>
<View className="flex flex-col space-y-2 p-4 mb-4">
<View className="">
<Text className="text-lg font-bold mb-4">Video</Text>
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
<View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} />
</View>
</View>
<View className="">
<Text className="text-lg font-bold mb-2">Audio</Text>
<Text className="text-lg font-bold mb-2">{t("item_card.audio")}</Text>
<AudioStreamInfo
audioStreams={
source?.MediaStreams?.filter(
@@ -70,7 +72,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
</View>
<View className="">
<Text className="text-lg font-bold mb-2">Subtitles</Text>
<Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text>
<SubtitleStreamInfo
subtitleStreams={
source?.MediaStreams?.filter(

View File

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

View File

@@ -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<typeof View> {
item: BaseItemDto;
@@ -29,14 +28,16 @@ export const MediaSourceSelector: React.FC<Props> = ({
[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)) {
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);
@@ -60,7 +61,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Video</Text>
<Text className="opacity-50 mb-1 text-xs">{t("item_card.video")}</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>

View File

@@ -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<Props> = ({
}) => {
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<Props> = ({
return (
<View {...props}>
<Text className="text-lg font-bold mb-2 px-4">
More with {actor?.Name}
{t("item_card.more_with", {name: actor?.Name})}
</Text>
<HorizontalScroll
data={items}

View File

@@ -2,6 +2,7 @@ import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { tc } from "@/utils/textTools";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps {
text?: string | null;
@@ -14,12 +15,13 @@ export const OverviewText: React.FC<Props> = ({
...props
}) => {
const [limit, setLimit] = useState(characterLimit);
const { t } = useTranslation();
if (!text) return null;
return (
<View className="flex flex-col" {...props}>
<Text className="text-lg font-bold mb-2">Overview</Text>
<Text className="text-lg font-bold mb-2">{t("item_card.overview")}</Text>
<TouchableOpacity
onPress={() =>
setLimit((prev) =>
@@ -31,7 +33,7 @@ export const OverviewText: React.FC<Props> = ({
<Text>{tc(text, limit)}</Text>
{text.length > characterLimit && (
<Text className="text-purple-600 mt-1">
{limit === characterLimit ? "Show more" : "Show less"}
{limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")}
</Text>
)}
</View>

View File

@@ -32,6 +32,7 @@ import Animated, {
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps<typeof Button> {
@@ -50,6 +51,7 @@ export const PlayButton: React.FC<Props> = ({
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus();
const { t } = useTranslation();
const [colorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
@@ -132,8 +134,8 @@ export const PlayButton: React.FC<Props> = ({
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;
}

View File

@@ -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<PreviousServersListProps> = ({
return JSON.parse(_previousServers || "[]") as Server[];
}, [_previousServers]);
const { t } = useTranslation();
if (!previousServers.length) return null;
return (
<View>
<ListGroup title="previous servers" className="mt-4">
<ListGroup title={t("server.previous_servers")} className="mt-4">
{previousServers.map((s) => (
<ListItem
key={s.address}
@@ -39,7 +42,7 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
onPress={() => {
setPreviousServers("[]");
}}
title={"Clear"}
title={t("server.clear_button")}
textColor="red"
/>
</ListGroup>

View File

@@ -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<SimilarItemsProps> = ({
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({
queryKey: ["similarItems", itemId],
@@ -47,12 +49,12 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
return (
<View {...props}>
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
<Text className="px-4 text-lg font-bold mb-2">{t("item_card.similar_items")}</Text>
<HorizontalScroll
data={movies}
loading={isLoading}
height={247}
noItemsText="No similar items found"
noItemsText={t("item_card.no_similar_items_found")}
renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
key={idx}

View File

@@ -5,6 +5,7 @@ import { Platform, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
@@ -37,6 +38,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
if (subtitleStreams.length === 0) return null;
const { t } = useTranslation();
return (
<View
className="flex col shrink justify-start place-self-start items-start"
@@ -48,12 +51,12 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col " {...props}>
<Text className="opacity-50 mb-1 text-xs">Subtitle</Text>
<Text className="opacity-50 mb-1 text-xs">{t("item_card.subtitles")}</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className=" ">
{selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: "None"}
: t("item_card.none")}
</Text>
</TouchableOpacity>
</View>

View File

@@ -1,14 +1,16 @@
import * as DropdownMenu from "zeego/dropdown-menu";
import {TouchableOpacity, View, ViewProps} from "react-native";
import {Text} from "@/components/common/Text";
import React, {PropsWithChildren, useEffect, useState} from "react";
import React, {PropsWithChildren, ReactNode, useEffect, useState} from "react";
import DisabledSetting from "@/components/settings/DisabledSetting";
interface Props<T> {
data: T[]
disabled?: boolean
placeholderText?: string,
keyExtractor: (item: T) => string
titleExtractor: (item: T) => string
title: string,
titleExtractor: (item: T) => string | undefined
title: string | ReactNode,
label: string,
onSelected: (...item: T[]) => void
multi?: boolean
@@ -16,6 +18,7 @@ interface Props<T> {
const Dropdown = <T extends unknown>({
data,
disabled,
placeholderText,
keyExtractor,
titleExtractor,
@@ -34,20 +37,30 @@ const Dropdown = <T extends unknown>({
}, [selected]);
return (
<View {...props}>
<DisabledSetting
disabled={disabled === true}
showText={false}
{...props}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">
{title}
</Text>
<TouchableOpacity
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText}
{typeof title === 'string' ? (
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">
{title}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText}
</Text>
</TouchableOpacity>
</View>
) : (
<>
{title}
</>
)}
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
@@ -88,7 +101,7 @@ const Dropdown = <T extends unknown>({
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
</DisabledSetting>
)
};

View File

@@ -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<FlashListProps<BaseItemDto>, "renderItem" | "data" | "style"> {
@@ -136,7 +137,7 @@ export function InfiniteHorizontalScroll({
showsHorizontalScrollIndicator={false}
ListEmptyComponent={
<View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">No data available</Text>
<Text className="text-center text-gray-500">{t("item_card.no_data_available")}</Text>
</View>
}
{...props}

View File

@@ -26,18 +26,6 @@ export const itemRouter = (
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
}
if (item.Type === "MusicAlbum") {
return `/(auth)/(tabs)/${from}/albums/${item.Id}`;
}
if (item.Type === "Audio") {
return `/(auth)/(tabs)/${from}/albums/${item.AlbumId}`;
}
if (item.Type === "MusicArtist") {
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
}
if (item.Type === "Person" || item.Type === "Actor") {
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
}

View File

@@ -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> = ({ ...props }) => {
if (processes?.length === 0)
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Active download</Text>
<Text className="opacity-50">No active downloads</Text>
<Text className="text-lg font-bold">{t("home.downloads.active_download")}</Text>
<Text className="opacity-50">{t("home.downloads.no_active_downloads")}</Text>
</View>
);
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold mb-2">Active downloads</Text>
<Text className="text-lg font-bold mb-2">{t("home.downloads.active_downloads")}</Text>
<View className="space-y-2">
{processes?.map((p) => (
<DownloadCard key={p.item.Id} process={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) => {
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
)}
{eta(process) && (
<Text className="text-xs">ETA {eta(process)}</Text>
<Text className="text-xs">{t("home.downloads.eta", {eta: eta(process)})}</Text>
)}
</View>

View File

@@ -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";

View File

@@ -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<T> extends ViewProps {
open: boolean;
@@ -76,6 +77,7 @@ export const FilterSheet = <T,>({
}: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["80%"], []);
const { t } = useTranslation();
const [data, setData] = useState<T[]>([]);
const [offset, setOffset] = useState<number>(0);
@@ -153,10 +155,10 @@ export const FilterSheet = <T,>({
>
<View className="px-4 mt-2 mb-8">
<Text className="font-bold text-2xl">{title}</Text>
<Text className="mb-2 text-neutral-500">{_data?.length} items</Text>
<Text className="mb-2 text-neutral-500">{t("search.items", {count: _data?.length})}</Text>
{showSearch && (
<Input
placeholder="Search..."
placeholder={t("search.search")}
className="my-2"
value={search}
onChangeText={(text) => {

View File

@@ -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 (
<View className="flex flex-co gap-y-4">
<ScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]}
title="Series"
title={t("favorites.series")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
title="Movies"
title={t("favorites.movies")}
hideIfEmpty
orientation="vertical"
/>
<ScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
title="Episodes"
title={t("favorites.episodes")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
title="Videos"
title={t("favorites.videos")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
title="Boxsets"
title={t("favorites.boxsets")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
title="Playlists"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteMusicAlbum}
queryKey={["home", "favorites", "musicAlbums"]}
title="Music Albums"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteAudio}
queryKey={["home", "favorites", "audio"]}
title="Audio"
title={t("favorites.playlists")}
hideIfEmpty
/>
</View>

View File

@@ -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 { useHaptic } from "@/hooks/useHaptic";
import { itemRouter } from "../common/TouchableItemRouter";
interface Props extends ViewProps {}
@@ -162,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)();

View File

@@ -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<Props> = ({
refetchOnReconnect: true,
});
if (disabled || !title) return null;
const { t } = useTranslation();
if (hideIfEmpty === true && data?.length === 0) return null;
if (disabled || !title) return null;
return (
<View {...props}>
@@ -50,7 +52,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
</Text>
{isLoading === false && data?.length === 0 && (
<View className="px-4">
<Text className="text-neutral-500">No items</Text>
<Text className="text-neutral-500">{t("home.no_items")}</Text>
</View>
)}
{isLoading ? (
@@ -104,7 +106,12 @@ export const ScrollingCollectionList: React.FC<Props> = ({
{item.Type === "Movie" && orientation === "vertical" && (
<MoviePoster item={item} />
)}
{item.Type === "Series" && <SeriesPoster item={item} />}
{item.Type === "Series" && orientation === "vertical" && (
<SeriesPoster item={item} />
)}
{item.Type === "Series" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Program" && (
<ContinueWatchingPoster item={item} />
)}

View File

@@ -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<StepperProps> = ({
value,
disabled,
step,
min,
max,
@@ -19,7 +22,11 @@ export const Stepper: React.FC<StepperProps> = ({
appendValue
}) => {
return (
<View className="flex flex-row items-center">
<DisabledSetting
disabled={disabled === true}
showText={false}
className="flex flex-row items-center"
>
<TouchableOpacity
onPress={() => 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<StepperProps> = ({
>
<Text>+</Text>
</TouchableOpacity>
</View>
</DisabledSetting>
)
}

View File

@@ -5,15 +5,17 @@ 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 && (
<View {...props}>
<Text className="text-lg font-bold mb-2 px-4">Cast</Text>
<Text className="text-lg font-bold mb-2 px-4">{t("jellyseerr.cast")}</Text>
<FlashList
horizontal
showsHorizontalScrollIndicator={false}

View File

@@ -9,6 +9,7 @@ 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;
@@ -50,6 +51,7 @@ 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";
@@ -144,21 +146,21 @@ const DetailFacts: React.FC<
return (
details && (
<View className="p-4">
<Text className="text-lg font-bold">Details</Text>
<Text className="text-lg font-bold">{t("jellyseerr.details")}</Text>
<View
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
{...props}
>
<Fact title="Status" fact={details?.status} />
<Fact title={t("jellyseerr.status")} fact={details?.status} />
<Fact
title="Original Title"
title={t("jellyseerr.original_title")}
fact={(details as TvDetails)?.originalName}
/>
{details.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID
) && <Fact title="Series Type" fact="Anime" />}
) && <Fact title={t("jellyseerr.series_type")} fact="Anime" />}
<Facts
title="Release Dates"
title={t("jellyseerr.release_dates")}
facts={filteredReleases?.map?.((r: Release, idx) => (
<View key={idx} className="flex flex-row space-x-2 items-center">
{r.type === 3 ? (
@@ -184,13 +186,13 @@ const DetailFacts: React.FC<
</View>
))}
/>
<Fact title="First Air Date" fact={firstAirDate} />
<Fact title="Next Air Date" fact={nextAirDate} />
<Fact title="Revenue" fact={revenue} />
<Fact title="Budget" fact={budget} />
<Fact title="Original Language" fact={spokenLanguage} />
<Fact title={t("jellyseerr.first_air_date")} fact={firstAirDate} />
<Fact title={t("jellyseerr.next_air_date")} fact={nextAirDate} />
<Fact title={t("jellyseerr.revenue")} fact={revenue} />
<Fact title={t("jellyseerr.budget")} fact={budget} />
<Fact title={t("jellyseerr.original_language")} fact={spokenLanguage} />
<Facts
title="Production Country"
title={t("jellyseerr.production_country")}
facts={details?.productionCountries?.map((n, idx) => (
<View key={idx} className="flex flex-row items-center space-x-2">
<CountryFlag isoCode={n.iso_3166_1} size={10} />
@@ -199,14 +201,14 @@ const DetailFacts: React.FC<
))}
/>
<Facts
title="Studios"
title={t("jellyseerr.studios")}
facts={uniqBy(details?.productionCompanies, "name")?.map(
(n) => n.name
)}
/>
<Facts title="Network" facts={networks?.map((n) => n.name)} />
<Facts title={t("jellyseerr.network")}facts={networks?.map((n) => n.name)} />
<Facts
title="Currently Streaming on"
title={t("jellyseerr.currently_streaming_on")}
facts={streamingProviders?.map((s) => s.name)}
/>
</View>

View File

@@ -20,6 +20,7 @@ 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;
@@ -28,6 +29,7 @@ interface Props extends ViewProps {
export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => {
const { jellyseerrApi } = useJellyseerr();
const opacity = useSharedValue(1);
const { t } = useTranslation();
const {
data: jellyseerrDiscoverSettings,
@@ -117,7 +119,7 @@ export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => {
!l2 && (
<View>
<Text className="text-center text-lg font-bold mt-4">
No results found for
{t("search.no_results_found_for")}
</Text>
<Text className="text-xs text-purple-600 text-center">
"{searchQuery}"
@@ -127,21 +129,21 @@ export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => {
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header="Request Movies"
header={t("search.request_movies")}
items={jellyseerrMovieResults}
renderItem={(item: MovieResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
header="Request Series"
header={t("search.request_series")}
items={jellyseerrTvResults}
renderItem={(item: TvResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
header="Actors"
header={t("search.actors")}
items={jellyseerrPersonResults}
renderItem={(item: PersonResult) => (
<PersonPoster

View File

@@ -10,6 +10,7 @@ import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/request
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;
@@ -36,6 +37,8 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
userId: jellyseerrUser?.id
});
const { t } = useTranslation();
const [modalRequestProps, setModalRequestProps] = useState<MediaRequestBody>();
const {data: serviceSettings} = useQuery({
@@ -103,7 +106,7 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
);
const seasonTitle = useMemo(
() => modalRequestProps?.seasons?.length ? `Season (${modalRequestProps?.seasons})` : undefined,
() => modalRequestProps?.seasons?.length ? t("jellyseerr.season_x", {seasons: modalRequestProps?.seasons}) : undefined,
[modalRequestProps?.seasons]
);
@@ -148,7 +151,7 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
return <BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">Advanced</Text>
<Text className="font-bold text-2xl text-neutral-100">{t("jellyseerr.advanced")}</Text>
{seasonTitle &&
<Text className="text-neutral-300">{seasonTitle}</Text>
}
@@ -161,27 +164,27 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
titleExtractor={(item) => item.name}
placeholderText={defaultProfile.name}
keyExtractor={(item) => item.id.toString()}
label={"Quality Profile"}
label={t("jellyseerr.quality_profile")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
profileId: item?.id
}))
}
title={"Quality Profile"}
title={t("jellyseerr.quality_profile")}
/>
<Dropdown
data={defaultServiceDetails.rootFolders}
titleExtractor={pathTitleExtractor}
placeholderText={defaultFolder ? pathTitleExtractor(defaultFolder) : ""}
keyExtractor={(item) => item.id.toString()}
label={"Root Folder"}
label={t("jellyseerr.root_folder")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
rootFolder: item.path
}))}
title={"Root Folder"}
title={t("jellyseerr.root_folder")}
/>
<Dropdown
multi={true}
@@ -189,28 +192,28 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
titleExtractor={(item) => item.label}
placeholderText={defaultTags.map(t => t.label).join(",")}
keyExtractor={(item) => item.id.toString()}
label={"Tags"}
label={t("jellyseerr.tags")}
onSelected={(...item) =>
item && setRequestOverrides((prev) => ({
...prev,
tags: item.map(i => i.id)
}))
}
title={"Tags"}
title={t("jellyseerr.tags")}
/>
<Dropdown
data={users}
titleExtractor={(item) => item.displayName}
placeholderText={jellyseerrUser!!.displayName}
keyExtractor={(item) => item.id.toString() || ""}
label={"Request As"}
label={t("jellyseerr.request_as")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
userId: item?.id
}))
}
title={"Request As"}
title={t("jellyseerr.request_as")}
/>
</>
)
@@ -221,7 +224,7 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
onPress={request}
color="purple"
>
Request
{t("jellyseerr.request_button")}
</Button>
</View>
</BottomSheetView>

View File

@@ -1,23 +1,30 @@
import React, {useCallback} from "react";
import {
useJellyseerr,
} from "@/hooks/useJellyseerr";
import {TouchableOpacity, ViewProps} from "react-native";
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
import {COMPANY_LOGO_IMAGE_FILTER, Network} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import {Studio} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
import {router, useSegments} from "expo-router";
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 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]);
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 (
<Slide
@@ -30,7 +37,10 @@ const CompanySlide: React.FC<{data: Network[] | Studio[]} & SlideProps & ViewPro
<GenericSlideCard
className="w-28 rounded-lg overflow-hidden border border-neutral-900 p-4"
id={item.id.toString()}
url={jellyseerrApi?.imageProxy(item.image, COMPANY_LOGO_IMAGE_FILTER)}
url={jellyseerrApi?.imageProxy(
item.image,
COMPANY_LOGO_IMAGE_FILTER
)}
/>
</TouchableOpacity>
)}

View File

@@ -1,55 +1,66 @@
import React, {useCallback} from "react";
import {Endpoints, useJellyseerr,} from "@/hooks/useJellyseerr";
import {TouchableOpacity, ViewProps} from "react-native";
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import {router, useSegments} from "expo-router";
import {useQuery} from "@tanstack/react-query";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import {genreColorMap} from "@/utils/jellyseerr/src/components/Discover/constants";
import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
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<SlideProps & ViewProps> = ({ 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 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],
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
})
enabled: !!jellyseerrApi,
});
return (
data && <Slide
{...props}
slide={slide}
data={data}
keyExtractor={(item) => item.id.toString()}
renderItem={(item, index) => (
<TouchableOpacity className="mr-2" onPress={() => navigate(item)}>
<GenericSlideCard
className="w-28 rounded-lg overflow-hidden border border-neutral-900"
id={item.id.toString()}
title={item.name}
colors={[]}
contentFit={"cover"}
url={jellyseerrApi?.imageProxy(item.backdrops?.[0], `w780_filter(duotone,${genreColorMap[item.id] ?? genreColorMap[0]})`)}
/>
</TouchableOpacity>
)}
/>
data && (
<Slide
{...props}
slide={slide}
data={data}
keyExtractor={(item) => item.id.toString()}
renderItem={(item, index) => (
<TouchableOpacity className="mr-2" onPress={() => navigate(item)}>
<GenericSlideCard
className="w-28 rounded-lg overflow-hidden border border-neutral-900"
id={item.id.toString()}
title={item.name}
colors={[]}
contentFit={"cover"}
url={jellyseerrApi?.imageProxy(
item.backdrops?.[0],
`w780_filter(duotone,${
genreColorMap[item.id] ?? genreColorMap[0]
})`
)}
/>
</TouchableOpacity>
)}
/>
)
);
};

View File

@@ -4,6 +4,7 @@ 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;
@@ -32,7 +33,7 @@ const Slide = <T extends unknown>({
return (
<View {...props}>
<Text className="font-bold text-lg mb-2 px-4">
{DiscoverSliderType[slide.type].toString().toTitle()}
{t("search." + DiscoverSliderType[slide.type].toString().toLowerCase())}
</Text>
<FlashList
horizontal

View File

@@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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;

View File

@@ -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;

View File

@@ -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<Props> = ({
collectionId,
artistId,
albumId,
songs = [],
...props
}) => {
const router = useRouter();
return (
<View className="flex flex-col space-y-2" {...props}>
{songs?.map((item: BaseItemDto, index: number) => (
<SongsListItem
key={item.Id}
item={item}
index={index}
collectionId={collectionId}
artistId={artistId}
albumId={albumId}
/>
))}
</View>
);
};

View File

@@ -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<Props> = ({
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 (
<TouchableOpacity
onPress={() => {
openSelect();
}}
{...props}
>
<View className="flex flex-row items-center space-x-4 bg-neutral-900 border-neutral-800 px-4 py-4 rounded-xl">
<Text className="opacity-50">{index + 1}</Text>
<View>
<Text className="mb-0.5 font-semibold">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{runtimeTicksToSeconds(item.RunTimeTicks)}
</Text>
</View>
</View>
</TouchableOpacity>
);
};

View File

@@ -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<ArtistPosterProps> = ({ 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 (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<Image
key={id}
id={id}
source={
url2
? {
uri: url2,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
if (item)
return (
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
};
export default AlbumCover;

View File

@@ -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<ArtistPosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() =>
getPrimaryImageUrl({
api,
item,
}),
[item]
);
if (!url)
return (
<View
className="rounded-lg overflow-hidden border border-neutral-900"
style={{
aspectRatio: "1/1",
}}
></View>
);
return (
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
};
export default ArtistPoster;

View File

@@ -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<Props> = ({ 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<Props> = ({ item, loading, ...props }) => {
return (
<View {...props} className="flex flex-col">
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
<Text className="text-lg font-bold mb-2 px-4">{t("item_card.cast_and_crew")}</Text>
<HorizontalScroll
loading={loading}
keyExtractor={(i, idx) => i.Id.toString()}

View File

@@ -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<Props> = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
const { t } = useTranslation();
return (
<View {...props}>
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
<Text className="text-lg font-bold mb-2 px-4">{t("item_card.series")}</Text>
<HorizontalScroll
data={[item]}
height={247}

View File

@@ -20,6 +20,7 @@ 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";
@@ -173,13 +174,13 @@ const JellyseerrSeasons: React.FC<{
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,
},
]),
@@ -207,7 +208,7 @@ const JellyseerrSeasons: React.FC<{
return (
<View>
<View className="flex flex-row justify-between items-end px-4">
<Text className="text-lg font-bold mb-2">Seasons</Text>
<Text className="text-lg font-bold mb-2">{t("item_card.seasons")}</Text>
{!allSeasonsAvailable && (
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
<Ionicons name="bag-add" color="white" size={26} />
@@ -227,7 +228,7 @@ const JellyseerrSeasons: React.FC<{
)}
ListHeaderComponent={() => (
<View className="flex flex-row justify-between items-end px-4">
<Text className="text-lg font-bold mb-2">Seasons</Text>
<Text className="text-lg font-bold mb-2">{t("item_card.seasons")}</Text>
{!allSeasonsAvailable && (
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
<Ionicons name="bag-add" color="white" size={26} />
@@ -255,8 +256,8 @@ const JellyseerrSeasons: React.FC<{
<Tags
textClass=""
tags={[
`Season ${season.seasonNumber}`,
`${season.episodeCount} Episodes`,
t("jellyseerr.season_number", {season_number: season.seasonNumber}),
t("jellyseerr.number_episodes", {episode_number: season.episodeCount}),
]}
/>
{[0].map(() => {

View File

@@ -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 (
<View className="px-4">
<Text className="text-lg font-bold mb-2">Next up</Text>
<Text className="opacity-50">No items to display</Text>
<Text className="text-lg font-bold mb-2">{t("item_card.next_up")}</Text>
<Text className="opacity-50">{t("item_card.no_items_to_display")}</Text>
</View>
);
return (
<View>
<Text className="text-lg font-bold px-4 mb-2">Next up</Text>
<Text className="text-lg font-bold px-4 mb-2">{t("item_card.next_up")}</Text>
<FlashList
contentContainerStyle={{ paddingLeft: 16 }}
horizontal

View File

@@ -3,6 +3,7 @@ import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { t } from "i18next";
type Props = {
item: BaseItemDto;
@@ -91,7 +92,7 @@ export const SeasonDropdown: React.FC<Props> = ({
<DropdownMenu.Trigger>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>Season {seasonIndex}</Text>
<Text>{t("item_card.season")} {seasonIndex}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
@@ -104,7 +105,7 @@ export const SeasonDropdown: React.FC<Props> = ({
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
<DropdownMenu.Label>{t("item_card.seasons")}</DropdownMenu.Label>
{seasons?.sort(sortByIndex).map((season: any) => (
<DropdownMenu.Item
key={season[keys.title]}

View File

@@ -17,7 +17,7 @@ import {
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { useTranslation } from "react-i18next";
type Props = {
item: BaseItemDto;
initialSeasonIndex?: number;
@@ -29,6 +29,7 @@ export const SeasonPicker: React.FC<Props> = ({ 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<Props> = ({ item, initialSeasonIndex }) => {
/>
{episodes?.length || 0 > 0 ? (
<DownloadItems
title="Download Season"
title={t("item_card.download.download_season")}
className="ml-2"
items={episodes || []}
MissingDownloadIconComponent={() => (
@@ -210,7 +211,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
{(episodes?.length || 0) === 0 ? (
<View className="flex flex-col">
<Text className="text-neutral-500">
No episodes for this season
{t("item_card.no_episodes_for_this_season")}
</Text>
</View>
) : null}

View File

@@ -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> = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const { t } = useTranslation();
if (!settings) return null;
return (
<View>
<ListGroup
title={t("home.settings.languages.title")}
>
<ListItem title={t("home.settings.languages.app_language")}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{APP_LANGUAGES.find(
(l) => l.value === settings?.preferedLanguage
)?.label || t("home.settings.languages.system")}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>
{t("home.settings.languages.title")}
</DropdownMenu.Label>
<DropdownMenu.Item
key={"unknown"}
onSelect={() => {
updateSettings({
preferedLanguage: undefined,
});
}}
>
<DropdownMenu.ItemTitle>
{t("home.settings.languages.system")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{APP_LANGUAGES?.map((l) => (
<DropdownMenu.Item
key={l?.value ?? "unknown"}
onSelect={() => {
updateSettings({
preferedLanguage: l.value,
});
}}
>
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
</ListGroup>
</View>
);
};

View File

@@ -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> = ({ ...props }) => {
const media = useMedia();
const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media;
const cultures = media.cultures;
const { t } = useTranslation();
if (!settings) return null;
return (
<View {...props}>
<ListGroup
title={"Audio"}
title={t("home.settings.audio.audio_title")}
description={
<Text className="text-[#8E8D91] text-xs">
Choose a default audio language.
{t("home.settings.audio.audio_hint")}
</Text>
}
>
<ListItem title={"Set Audio Track From Previous Item"}>
<ListItem
title={t("home.settings.audio.set_audio_track")}
disabled={pluginSettings?.rememberAudioSelections?.locked}
>
<Switch
value={settings.rememberAudioSelections}
disabled={pluginSettings?.rememberAudioSelections?.locked}
onValueChange={(value) =>
updateSettings({ rememberAudioSelections: value })
}
/>
</ListItem>
<ListItem title="Audio language">
<ListItem title={t("home.settings.audio.audio_language")}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
<Text className="mr-1 text-[#8E8D91]">
{settings?.defaultAudioLanguage?.DisplayName || "None"}
{settings?.defaultAudioLanguage?.DisplayName || t("home.settings.audio.none")}
</Text>
<Ionicons
name="chevron-expand-sharp"
@@ -57,7 +65,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Label>{t("home.settings.audio.language")}</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-audio"}
onSelect={() => {
@@ -66,7 +74,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
<DropdownMenu.ItemTitle>{t("home.settings.audio.none")}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{cultures?.map((l) => (
<DropdownMenu.Item

View File

@@ -0,0 +1,26 @@
import {View, ViewProps} from "react-native";
import {Text} from "@/components/common/Text";
const DisabledSetting: React.FC<{disabled: boolean, showText?: boolean, text?: string} & ViewProps> = ({
disabled = false,
showText = true,
text,
children,
...props
}) => (
<View
pointerEvents={disabled ? "none" : "auto"}
style={{
opacity: disabled ? 0.5 : 1,
}}
>
<View {...props}>
{disabled && showText &&
<Text className="text-center text-red-700 my-4">{text ?? "Currently disabled by admin."}</Text>
}
{children}
</View>
</View>
)
export default DisabledSetting;

View File

@@ -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 (
<View {...props} className="mb-4">
<ListGroup title="Downloads">
<ListItem title="Download method">
<DisabledSetting disabled={allDisabled} {...props} className="mb-4">
<ListGroup title={t("home.settings.downloads.downloads_title")}>
<ListItem
title={t("home.settings.downloads.download_method")}
disabled={pluginSettings?.downloadMethod?.locked}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings.downloadMethod === "remux"
? "Default"
: "Optimized"}
{settings.downloadMethod === DownloadMethod.Remux
? t("home.settings.downloads.default")
: t("home.settings.downloads.optimized")}
</Text>
<Ionicons
name="chevron-expand-sharp"
@@ -47,33 +61,36 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Methods</DropdownMenu.Label>
<DropdownMenu.Label>{t("home.settings.downloads.methods")}</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ downloadMethod: "remux" });
updateSettings({ downloadMethod: DownloadMethod.Remux });
setProcesses([]);
}}
>
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
<DropdownMenu.ItemTitle>{t("home.settings.downloads.default")}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ downloadMethod: "optimized" });
updateSettings({ downloadMethod: DownloadMethod.Optimized });
setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
<DropdownMenu.ItemTitle>{t("home.settings.downloads.optimized")}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
<ListItem
title="Remux max download"
disabled={settings.downloadMethod !== "remux"}
title={t("home.settings.downloads.remux_max_download")}
disabled={
pluginSettings?.remuxConcurrentLimit?.locked ||
settings.downloadMethod !== DownloadMethod.Remux
}
>
<Stepper
value={settings.remuxConcurrentLimit}
@@ -89,23 +106,32 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
</ListItem>
<ListItem
title="Auto download"
disabled={settings.downloadMethod !== "optimized"}
title={t("home.settings.downloads.auto_download")}
disabled={
pluginSettings?.autoDownload?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
>
<Switch
disabled={settings.downloadMethod !== "optimized"}
disabled={
pluginSettings?.autoDownload?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
value={settings.autoDownload}
onValueChange={(value) => updateSettings({ autoDownload: value })}
/>
</ListItem>
<ListItem
disabled={settings.downloadMethod !== "optimized"}
disabled={
pluginSettings?.optimizedVersionsServerUrl?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
onPress={() => router.push("/settings/optimized-server/page")}
showArrow
title="Optimized Versions Server"
title={t("home.settings.downloads.optimized_versions_server")}
></ListItem>
</ListGroup>
</View>
</DisabledSetting>
);
};

View File

@@ -2,6 +2,7 @@ import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { useState } from "react";
import { View } from "react-native";
@@ -20,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<boolean>(false);
@@ -47,7 +50,7 @@ export const JellyseerrSettings = () => {
updateSettings({ jellyseerrServerUrl });
},
onError: () => {
toast.error("Failed to login");
toast.error(t("jellyseerr.failed_to_login"));
},
onSettled: () => {
setJellyseerrPassword(undefined);
@@ -89,53 +92,50 @@ export const JellyseerrSettings = () => {
<>
<ListGroup title={"Jellyseerr"}>
<ListItem
title="Total media requests"
title={t("home.settings.plugins.jellyseerr.total_media_requests")}
value={jellyseerrUser?.requestCount?.toString()}
/>
<ListItem
title="Movie quota limit"
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
value={
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
jellyseerrUser?.movieQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title="Movie quota days"
title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
value={
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
jellyseerrUser?.movieQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title="TV quota limit"
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
/>
<ListItem
title="TV quota days"
value={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
value={jellyseerrUser?.tvQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
/>
</ListGroup>
<View className="p-4">
<Button color="red" onPress={clearData}>
Reset Jellyseerr config
{t("home.settings.plugins.jellyseerr.reset_jellyseerr_config_button")}
</Button>
</View>
</>
) : (
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
<Text className="text-xs text-red-600 mb-2">
This integration is in its early stages. Expect things to change.
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className="font-bold mb-1">Server URL</Text>
<Text className="font-bold mb-1">{t("home.settings.plugins.jellyseerr.server_url")}</Text>
<View className="flex flex-col shrink mb-2">
<Text className="text-xs text-gray-600">
Example: http(s)://your-host.url
</Text>
<Text className="text-xs text-gray-600">
(add port if required)
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
</View>
<Input
placeholder="Jellyseerr URL..."
placeholder={t("home.settings.plugins.jellyseerr.server_url_placeholder")}
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
@@ -165,7 +165,7 @@ export const JellyseerrSettings = () => {
marginBottom: 8,
}}
>
{promptForJellyseerrPass ? "Clear" : "Save"}
{promptForJellyseerrPass ? t("home.settings.plugins.jellyseerr.clear_button") : t("home.settings.plugins.jellyseerr.save_button")}
</Button>
<View
@@ -174,11 +174,11 @@ export const JellyseerrSettings = () => {
opacity: promptForJellyseerrPass ? 1 : 0.5,
}}
>
<Text className="font-bold mb-2">Password</Text>
<Text className="font-bold mb-2">{t("home.settings.plugins.jellyseerr.password")}</Text>
<Input
autoFocus={true}
focusable={true}
placeholder={`Enter password for Jellyfin user ${user?.Name}`}
placeholder={t("home.settings.plugins.jellyseerr.password_placeholder", {username: user?.Name})}
value={jellyseerrPassword}
keyboardType="default"
secureTextEntry={true}
@@ -198,7 +198,7 @@ export const JellyseerrSettings = () => {
className="h-12 mt-2"
onPress={() => loginToJellyseerrMutation.mutate()}
>
Login
{t("home.settings.plugins.jellyseerr.login_button")}
</Button>
</View>
</View>

View File

@@ -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> = ({ ...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
) => (
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={onDecrease}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{value}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={onIncrease}
>
<Text>+</Text>
</TouchableOpacity>
</View>
);
const disabled = useMemo(() => (
pluginSettings?.forwardSkipTime?.locked === true &&
pluginSettings?.rewindSkipTime?.locked === true
),
[pluginSettings]
)
return (
<View {...props}>
<ListGroup title="Media Controls">
<ListItem title="Forward Skip Length">
{renderSkipControl(
settings.forwardSkipTime,
() =>
updateSettings({
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
}),
() =>
updateSettings({
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
})
)}
<DisabledSetting
disabled={disabled}
{...props}
>
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
<ListItem
title={t("home.settings.media_controls.forward_skip_length")}
disabled={pluginSettings?.forwardSkipTime?.locked}
>
<Stepper
value={settings.forwardSkipTime}
disabled={pluginSettings?.forwardSkipTime?.locked}
step={5}
appendValue={t("home.settings.media_controls.seconds_unit")}
min={0}
max={60}
onUpdate={(forwardSkipTime) => updateSettings({forwardSkipTime})}
/>
</ListItem>
<ListItem title="Rewind Length">
{renderSkipControl(
settings.rewindSkipTime,
() =>
updateSettings({
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
}),
() =>
updateSettings({
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
})
)}
<ListItem
title={t("home.settings.media_controls.rewind_length")}
disabled={pluginSettings?.rewindSkipTime?.locked}
>
<Stepper
value={settings.rewindSkipTime}
disabled={pluginSettings?.rewindSkipTime?.locked}
step={5}
appendValue={t("home.settings.media_controls.seconds_unit")}
min={0}
max={60}
onUpdate={(rewindSkipTime) => updateSettings({rewindSkipTime})}
/>
</ListItem>
</ListGroup>
</View>
</DisabledSetting>
);
};

View File

@@ -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<Props> = ({
Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
};
const { t } = useTranslation();
return (
<View>
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
<View className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}>
<Text className="mr-4">URL</Text>
<Text className="mr-4">{t("home.settings.downloads.url")}</Text>
<TextInput
className="text-white"
placeholder="http(s)://domain.org:port"
placeholder={t("home.settings.downloads.server_url_placeholder")}
value={value}
keyboardType="url"
returnKeyType="done"
@@ -32,10 +35,9 @@ export const OptimizedServerForm: React.FC<Props> = ({
</View>
</View>
<Text className="px-4 text-xs text-neutral-500 mt-1">
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")}{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
Read more about the optimize server.
{t("home.settings.downloads.read_more_about_optimized_server")}
</Text>
</Text>
</View>

View File

@@ -9,19 +9,21 @@ 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 router = useRouter();
const [settings, updateSettings] = useSettings();
const [settings, updateSettings, pluginSettings] = useSettings();
const { t } = useTranslation();
/********************
* Background task
@@ -53,146 +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 (
<ListGroup title="Other" className="">
<ListItem title="Auto rotate">
<Switch
value={settings.autoRotate}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</ListItem>
<DisabledSetting disabled={disabled}>
<ListGroup title={t("home.settings.other.other_title")} className="">
<ListItem
title={t("home.settings.other.auto_rotate")}
disabled={pluginSettings?.autoRotate?.locked}
>
<Switch
value={settings.autoRotate}
disabled={pluginSettings?.autoRotate?.locked}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</ListItem>
<ListItem title="Video orientation" disabled={settings.autoRotate}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
</Text>
<Ionicons name="chevron-expand-sharp" size={18} color="#5A5960" />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.DEFAULT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.DEFAULT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.PORTRAIT_UP,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.PORTRAIT_UP
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="4"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
<ListItem
title={t("home.settings.other.video_orientation")}
disabled={
pluginSettings?.defaultVideoOrientation?.locked ||
settings.autoRotate
}
>
<Dropdown
data={orientations}
disabled={
pluginSettings?.defaultVideoOrientation?.locked ||
settings.autoRotate
}
keyExtractor={String}
titleExtractor={(item) => ScreenOrientationEnum[item]}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{t(ScreenOrientationEnum[settings.defaultVideoOrientation])}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
}
label={t("home.settings.other.orientation")}
onSelected={(defaultVideoOrientation) =>
updateSettings({ defaultVideoOrientation })
}
/>
</ListItem>
<ListItem title="Safe area in controls">
<Switch
value={settings.safeAreaInControlsEnabled}
onValueChange={(value) =>
updateSettings({ safeAreaInControlsEnabled: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.other.safe_area_in_controls")}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
>
<Switch
value={settings.safeAreaInControlsEnabled}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
onValueChange={(value) =>
updateSettings({ safeAreaInControlsEnabled: value })
}
/>
</ListItem>
<ListItem
title="Show Custom Menu Links"
onPress={() =>
Linking.openURL(
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
)
}
>
<Switch
value={settings.showCustomMenuLinks}
onValueChange={(value) =>
updateSettings({ showCustomMenuLinks: value })
<ListItem
title={t("home.settings.other.show_custom_menu_links")}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onPress={() =>
Linking.openURL(
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
)
}
>
<Switch
value={settings.showCustomMenuLinks}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onValueChange={(value) =>
updateSettings({ showCustomMenuLinks: value })
}
/>
</ListItem>
<ListItem
onPress={() => router.push("/settings/hide-libraries/page")}
title={t("home.settings.other.hide_libraries")}
showArrow
/>
</ListItem>
<ListItem
onPress={() => router.push("/settings/hide-libraries/page")}
title="Hide Libraries"
showArrow
/>
<ListItem title="Disable Haptic Feedback">
<Switch
value={settings.disableHapticFeedback}
onValueChange={(value) =>
updateSettings({ disableHapticFeedback: value })
}
/>
</ListItem>
</ListGroup>
<ListItem
title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked}
>
<Switch
value={settings.disableHapticFeedback}
disabled={pluginSettings?.disableHapticFeedback?.locked}
onValueChange={(disableHapticFeedback) =>
updateSettings({ disableHapticFeedback })
}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);
};

View File

@@ -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 (
<View>
<ListGroup title="Plugins">
<ListGroup title={t("home.settings.plugins.plugins_title")} className="mb-4">
<ListItem
onPress={() => router.push("/settings/jellyseerr/page")}
title={"Jellyseerr"}
@@ -24,11 +27,6 @@ export const PluginSettings = () => {
title="Marlin Search"
showArrow
/>
<ListItem
onPress={() => router.push("/settings/popular-lists/page")}
title="Popular Lists"
showArrow
/>
</ListGroup>
</View>
);

View File

@@ -7,6 +7,7 @@ import {
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
import { useAtom } from "jotai";
import React, { useCallback, useRef, useState } from "react";
@@ -26,6 +27,8 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const { t } = useTranslation();
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
@@ -46,26 +49,26 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
});
if (res.status === 200) {
successHapticFeedback();
Alert.alert("Success", "Quick connect authorized");
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("Error", "Invalid code");
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
}
} catch (e) {
errorHapticFeedback();
Alert.alert("Error", "Invalid code");
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
}
}
}, [api, user, quickConnectCode]);
return (
<View {...props}>
<ListGroup title={"Quick Connect"}>
<ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
<ListItem
onPress={() => bottomSheetModalRef?.current?.present()}
title="Authorize Quick Connect"
title={t("home.settings.quick_connect.authorize_button")}
textColor="blue"
/>
</ListGroup>
@@ -85,7 +88,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">
Quick Connect
{t("home.settings.quick_connect.quick_connect_title")}
</Text>
</View>
<View className="flex flex-col space-y-2">
@@ -93,7 +96,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
<BottomSheetTextInput
style={{ color: "white" }}
clearButtonMode="always"
placeholder="Enter the quick connect code..."
placeholder={t("home.settings.quick_connect.enter_the_quick_connect_code")}
placeholderTextColor="#9CA3AF"
value={quickConnectCode}
onChangeText={setQuickConnectCode}
@@ -105,7 +108,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
onPress={authorizeQuickConnect}
color="purple"
>
Authorize
{t("home.settings.quick_connect.authorize")}
</Button>
</View>
</BottomSheetView>

View File

@@ -1,18 +1,17 @@
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 { useHaptic } from "@/hooks/useHaptic";
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");
@@ -34,7 +33,7 @@ export const StorageSettings = () => {
successHapticFeedback();
} catch (e) {
errorHapticFeedback();
toast.error("Error deleting files");
toast.error(t("home.settings.toasts.error_deleting_files"));
}
};
@@ -46,11 +45,10 @@ export const StorageSettings = () => {
<View>
<View className="flex flex-col gap-y-1">
<View className="flex flex-row items-center justify-between">
<Text className="">Storage</Text>
<Text className="">{t("home.settings.storage.storage_title")}</Text>
{size && (
<Text className="text-neutral-500">
{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()})}
</Text>
)}
</View>
@@ -81,18 +79,13 @@ export const StorageSettings = () => {
<View className="flex flex-row items-center">
<View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View>
<Text className="text-white text-xs">
App {calculatePercentage(size.app, size.total)}%
{t("home.settings.storage.app_usage", {usedSpace: calculatePercentage(size.app, size.total)})}
</Text>
</View>
<View className="flex flex-row items-center">
<View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View>
<Text className="text-white text-xs">
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)})}
</Text>
</View>
</>
@@ -103,7 +96,7 @@ export const StorageSettings = () => {
<ListItem
textColor="red"
onPress={onDeleteClicked}
title="Delete All Downloaded Files"
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
</View>

View File

@@ -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> = ({ ...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> = ({ ...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 (
<View {...props}>
<ListGroup
title={"Subtitles"}
title={t("home.settings.subtitles.subtitle_title")}
description={
<Text className="text-[#8E8D91] text-xs">
Configure subtitle preferences.
{t("home.settings.subtitles.subtitle_hint")}
</Text>
}
>
<ListItem title="Subtitle language">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
<Dropdown
data={[{DisplayName: t("home.settings.subtitles.none"), ThreeLetterISOLanguageName: "none-subs" },...(cultures ?? [])]}
keyExtractor={(item) => item?.ThreeLetterISOLanguageName ?? "unknown"}
titleExtractor={(item) => item?.DisplayName}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
{settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")}
</Text>
<Ionicons
name="chevron-expand-sharp"
@@ -48,51 +65,31 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
color="#5A5960"
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-subs"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{cultures?.map((l) => (
<DropdownMenu.Item
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>
{l.DisplayName}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
}
label={t("home.settings.subtitles.language")}
onSelected={(defaultSubtitleLanguage) =>
updateSettings({
defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === t("home.settings.subtitles.none")
? null
: defaultSubtitleLanguage
})
}
/>
</ListItem>
<ListItem title="Subtitle Mode">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<ListItem
title={t("home.settings.subtitles.subtitle_mode")}
disabled={pluginSettings?.subtitleMode?.locked}
>
<Dropdown
data={subtitleModes}
disabled={pluginSettings?.subtitleMode?.locked}
keyExtractor={String}
titleExtractor={(item) => t(subtitleModeKeys[item]) || String(item)}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings?.subtitleMode || "Loading"}
{t(subtitleModeKeys[settings?.subtitleMode]) || t("home.settings.subtitles.loading")}
</Text>
<Ionicons
name="chevron-expand-sharp"
@@ -100,68 +97,39 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
color="#5A5960"
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitle Mode</DropdownMenu.Label>
{subtitleModes?.map((l) => (
<DropdownMenu.Item
key={l}
onSelect={() => {
updateSettings({
subtitleMode: l,
});
}}
>
<DropdownMenu.ItemTitle>{l}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
}
label={t("home.settings.subtitles.subtitle_mode")}
onSelected={(subtitleMode) =>
updateSettings({subtitleMode})
}
/>
</ListItem>
<ListItem title="Set Subtitle Track From Previous Item">
<ListItem
title={t("home.settings.subtitles.set_subtitle_track")}
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
>
<Switch
value={settings.rememberSubtitleSelections}
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
onValueChange={(value) =>
updateSettings({ rememberSubtitleSelections: value })
}
/>
</ListItem>
<ListItem title="Subtitle Size">
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
subtitleSize: Math.max(0, settings.subtitleSize - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 px-3 py-2 flex items-center justify-center">
{settings.subtitleSize}
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
subtitleSize: Math.min(120, settings.subtitleSize + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
<ListItem
title={t("home.settings.subtitles.subtitle_size")}
disabled={pluginSettings?.subtitleSize?.locked}
>
<Stepper
value={settings.subtitleSize}
disabled={pluginSettings?.subtitleSize?.locked}
step={5}
min={0}
max={120}
onUpdate={(subtitleSize) => updateSettings({subtitleSize})}
/>
</ListItem>
</ListGroup>
</View>

View File

@@ -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> = ({ ...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> = ({ ...props }) => {
return (
<View {...props}>
<ListGroup title={"User Info"}>
<ListItem title="User" value={user?.Name} />
<ListItem title="Server" value={api?.basePath} />
<ListItem title="Token" value={api?.accessToken} />
<ListItem title="App version" value={version} />
<ListGroup title={t("home.settings.user_info.user_info_title")}>
<ListItem title={t("home.settings.user_info.user")} value={user?.Name} />
<ListItem title={t("home.settings.user_info.server")} value={api?.basePath} />
<ListItem title={t("home.settings.user_info.token")} value={api?.accessToken} />
<ListItem title={t("home.settings.user_info.app_version")} value={version} />
</ListGroup>
</View>
);

View File

@@ -17,14 +17,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerLeft: () => <HeaderBackButton />,
};
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<string, ICommonScreenOptions> =
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));

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