From 752cb1cdc67d26061715fd5861fee60ec0fe4d0a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 18 Aug 2024 17:10:31 +0200 Subject: [PATCH 01/34] wip --- app.json | 5 ++-- app/(auth)/(tabs)/_layout.tsx | 20 +++++++-------- app/(auth)/(tabs)/home/_layout.tsx | 4 ++- app/(auth)/(tabs)/home/index.tsx | 25 ++++++++++--------- app/_layout.tsx | 15 +++++++++++- app/login.tsx | 28 +++++++++++---------- bun.lockb | Bin 562661 -> 565028 bytes components/LanguageSwitcher.tsx | 36 +++++++++++++++++++++++++++ i18n.ts | 22 +++++++++++++++++ package.json | 3 +++ translations/en.json | 38 +++++++++++++++++++++++++++++ translations/sv.json | 38 +++++++++++++++++++++++++++++ utils/atoms/settings.ts | 3 +++ 13 files changed, 198 insertions(+), 39 deletions(-) create mode 100644 components/LanguageSwitcher.tsx create mode 100644 i18n.ts create mode 100644 translations/en.json create mode 100644 translations/sv.json diff --git a/app.json b/app.json index 71adc8e7..cf0e5b0f 100644 --- a/app.json +++ b/app.json @@ -30,7 +30,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 17, + "versionCode": 18, "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png" }, @@ -96,7 +96,8 @@ { "motionPermission": "Allow Streamyfin to access your device motion for landscape video watching." } - ] + ], + "expo-localization" ], "experiments": { "typedRoutes": true diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index c9e80906..8b0472b3 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -1,15 +1,15 @@ -import { router, Tabs } from "expo-router"; -import React, { useEffect } from "react"; -import * as NavigationBar from "expo-navigation-bar"; import { TabBarIcon } from "@/components/navigation/TabBarIcon"; import { Colors } from "@/constants/Colors"; -import { Platform, TouchableOpacity, View } from "react-native"; -import { Feather } from "@expo/vector-icons"; -import { Chromecast } from "@/components/Chromecast"; import { BlurView } from "expo-blur"; -import { StyleSheet } from "react-native"; +import * as NavigationBar from "expo-navigation-bar"; +import { Tabs } from "expo-router"; +import React, { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, StyleSheet } from "react-native"; export default function TabLayout() { + const { t } = useTranslation(); + useEffect(() => { if (Platform.OS === "android") { NavigationBar.setBackgroundColorAsync("#121212"); @@ -53,7 +53,7 @@ export default function TabLayout() { name="home" options={{ headerShown: false, - title: "Home", + title: t("tabs.home"), tabBarIcon: ({ color, focused }) => ( ( ), @@ -76,7 +76,7 @@ export default function TabLayout() { name="library" options={{ headerShown: false, - title: "Library", + title: t("tabs.library"), tabBarIcon: ({ color, focused }) => ( - No Internet + {t("home.noInternet")} - No worries, you can still watch{"\n"}downloaded content. + {t("home.noInternetMessage")} @@ -239,10 +242,8 @@ export default function index() { if (isError) return ( - Oops! - - Something went wrong.{"\n"}Please log out and in again. - + {t("home.oops")} + {t("home.errorMessage")} ); @@ -265,14 +266,14 @@ export default function index() { - + + + ); } @@ -52,6 +57,8 @@ function Layout() { useKeepAwake(); + const { i18n } = useTranslation(); + const queryClientRef = useRef( new QueryClient({ defaultOptions: { @@ -75,6 +82,12 @@ function Layout() { ); }, [settings]); + useEffect(() => { + i18n.changeLanguage( + settings?.preferedLanguage || getLocales()[0].languageCode || "en" + ); + }, [settings]); + return ( diff --git a/app/login.tsx b/app/login.tsx index d4c1d51c..9d99f086 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -1,11 +1,13 @@ import { Button } from "@/components/Button"; import { Input } from "@/components/common/Input"; import { Text } from "@/components/common/Text"; +import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { Ionicons } from "@expo/vector-icons"; import { AxiosError } from "axios"; import { useAtom } from "jotai"; import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Alert, KeyboardAvoidingView, @@ -21,6 +23,7 @@ const CredentialsSchema = z.object({ }); const Login: React.FC = () => { + const { t, i18n } = useTranslation(); const { setServer, login, removeServer } = useJellyfin(); const [api] = useAtom(apiAtom); @@ -72,7 +75,7 @@ const Login: React.FC = () => { Streamyfin - Server: {api.basePath} + {t("server.server_label", { serverURL: api.basePath })} - Log in + {t("login.login")} - Log in to any user account + {t("login.login_subtitle")} setCredentials({ ...credentials, username: text }) } @@ -116,7 +119,7 @@ const Login: React.FC = () => { setCredentials({ ...credentials, password: text }) } @@ -139,7 +142,7 @@ const Login: React.FC = () => { loading={loading} className="mt-auto mb-2" > - Log in + {t("login.login_button")} @@ -158,10 +161,10 @@ const Login: React.FC = () => { Streamyfin - Connect to your Jellyfin server + {t("server.connect_to_server")} { textContentType="URL" maxLength={500} /> - - Server URL requires http or https - + {t("server.server_url_hint")} + diff --git a/bun.lockb b/bun.lockb index fea3b82928cb89a95a1eb9abf105f4bff1d3a484..cf902f6f21bc9027d7606b7b153201978c60b542 100755 GIT binary patch delta 92642 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 delta 91159 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

l2`4?M&8u}6J;v2 zKLg^Th4ZNh2ig>C$NSqP(FO#*v5h5gJ+wv^x8u5{>}%)#pVMjHI-Q1-csO822eSY6 z*)$RVdN$3;j#Tu=&Zdc=^Ex_FQUA%=G!Z`N9%X*-Y?_&!+%s9+`cKY?@b2RA-2BA; zPG!_*9MnRfAqL>{Y`?_?7?Y%Uos+i&Mzqc>lGm(w!>dYN+d+lG1{%*@>gWkSm zwyK-K;hjx=Iuhw1XdNVWSoZa#U&oS5J)?&zv)4sR_OkdIF>rcH$F_H`y*M`0;aKSM z*YU@$Tx(w9nWi2OO z_x(dpz|lkLm>*7PE>V5lY5X_9q005qz23ue45+Nj!uT$*E7TU-*oPV9Q8;vtxuJCW z981qess}`8tC$sY#nIP&$F?0)rHAnU1rnJibxxlL|MHdX~h8=e@H_7NAJrlssXG1(3+ zu9?I01@0%?Pz(y}BgvwO-h8B2EXrNeYgDX^a<{PstP8K!?zoR{!?1n%&V8+={{VNI zuv4vVdvN*DPjB8I_E&LxFNFFpuJR-qRXSkz)t0AH9BX4&x_ah}=iO{Idf0l(K$;nw zTCElkGGJxKJ6}&^qeA}>`xpj$6NFS6h>Q|S>wz@!|HXUwLf+oP=O=>)kKIQ>{X+SVGNy^}>(0lmJt`QzQ8VFNqZV?ee%OY$GQ-|n{6*_lRjQnz_B;Jd^A z?pOi3s${0uV!to{@I8*C2d1!J4d?jiy6uIE>?FR~KnM50z!-Q;yCv5uo{d#%J_@3z=#t^S3c~n;oq;D zZBQk8o_0;vo`duxHB~tnQDeKw;05kavd(}b9R>QZ+l{yaac`f6Gv0{qw%L+dTnkyf z^xoR#bw>7(S_|F5;p2PQ-pcQ?m2Z7rZtAW_vlcd!Cu%%rA9i5HZ{L);!xP&}>0@e9 z&MkDOHB}nyOx;rgR#UZhUza+I+@a1cy=2rPX!h~FPWGVgUE~h--`UHK`N_DBS^j=J z@{uJ5OOqV!C5aci(}e#n_MWpTNux@HG+JH8;^33pAfF$2Hzm1>_vPpQ`4Yv?%9Buz zZ$v`(dPzgxb-H>>_r)}hhEr`P)UA8l)9%gq>WmdfgsSgkedMa9lCFY)9Ia&I)`A^^rVFqEiVD|E8}!Mg(-;+~t1lR#%9_ zcpa~pCu>*r%6et%v#(>GJnAc}HIBIbWacurU)tXG7?J1G=?lumTRja%<`b(d)Ch`d;O(%kCdKwqKAo*=Ch=~w1C6;q`yqna6qJ-SW1H%&2K&F^yYb-W8Ir> zT3OFT9bq3RP7>d|j2>_A#1S2JB~UktCzT<~86^`8 zvc+7a+obqYj+8vD$51lKqFWnN-I->PJXnrEY=hgGYmls5MTGx*OzM9dTcAofj)_%IG z8Z!AY7<`9GwYBa~vSb}*FKYbLm7Lf6L}n3Hctd{AK~1V(GgaE&a#Cb^tBqMU%|Bml z3$IR%_YaqQYw6~(#I=t4{;!Id#w!eCr#TCcl=TOMMW>u*9I`yD-W^PmWRttV+ox8y*o0g@{=J-Y#+WIak-YuM z9cw!wn~{nAL9Fw}m@P9mGbeY>A1iaWpzl_UwHv|K2icBJd>prJOeQ{FWgExJ1KwrZ zM$k@L+HRuI#jWR(r^NY-=^G8)Qtmg# zN@qBn55~%vZE(JVQ(Nu51q%abRerg^ip$b_3CGE9I8gxlaf8QyoUM(!6s$SDVEUE2 ztSBwUbJA=F<7?h=a+w&N#l}f-4Qq{)&$gikO-kWi$J;f3Y)#SaEov3csGczWc9|b^ z;fdzlWu{l>`XOD8gF;E z@}D`$mhSBE&#SIG*t#pZ)Fbstg|FqVZ^t8%ak9ON^!QP>J@Z4tPr_lw+5nztGn>Ee zP5c+VFI2SNeXE30b_e>@Ds70fugH`gH1dXWa|f$K9~YxlXO+>*kvp-ywoH?8d(H8< zho{NjJ)8&e(@wlNXQ$bTPplZRdVT&PX-V8t(B3UR61vNs+<9%9e7eh>(V2I;G`>OI zSl>Q6Bc{vjUG6j~8}l3j#AU*8|D9Yy4-`q=u%0)oB5>qYSetB zM&mJuXuthECCY)XyZxMlXGqaK^q{db?3Q;reU-rzGCW`u@qSKf?=y5xpCP-?(ySK4 zs8!lF=j*Xa+ced!lIFWpyVPl%>u1QglQfe(GvxX?Li=EH88V;=_H z%Ay)9!dlYvS;rIgGq-!bIkf2SC5Og5sXa@o@NU@04??;^viiAUovOJnbv@N5a5XqWn%(=`lMHP=>as5LxysZ-6I*dY9gMIjmpSMeTW-PFq zaHobHim(5o2fNX%k%xw0PKYp(oBI0?8op#aj-EBISviT;9orU4({HH=`)qf4aGVAH zw;8>sQdp@B@ILyup7R#QtDDYFleuTmW>Xj0Ei3Wwb4E4D+gdx9{`v;Ch0#dii{a1# z>(cIu3vP{R(-01wfl%ztJW0lrm(6|tF8L<)B%WZp=#%%fmnls65nf2a3!D4&yOZH) zLe;Tm-)*+}_C#Vv{2tqmwK{Yv=E;+}vKm2nF7mFrhd%Gzzgn|lGj%0JjZkJWc3b`M z5Rv$lJEPecp{=CCDO!nj`ZXVZGV!+?iLZ++^K?B=mFu>KoE4JxG~xOsQsp%1e?0H; zWWZ^9&}f;Vf$2ztm&$2g^B#~hb*DS6_@AM7f2b182xIERtNZ2b89Mbl#+hf+GGk(m z?|ZG}+#cg%r0{k__Kt1dA!|2pX%9+ODdEG7yZcy%fqhhhcWm$uNAC?oVaIt>D?i2g z%mEA6&Dc$PM)6!SPmpVM(KU8BTlh!#ke*b;bz`y)TyG8~ksXMi8Wh+%P9KKf*#`N~K z3GZ-Phxc_jZLns?wr_X*a4SX}VSbSt#hsx?7$F2Qyz;TvYCBuGjM|lQvZi;SJ zD}CX>v|D@9tRWr!i~;G`QgJpKEk%E%oyXW(b)?IWjCgi?wB;0jVU=xMt*msvUiH++ zx5s4n<7Ss>ykey); zMiO=9eXa4&`tn>^V{6u#>qm|XO6W?KGTKdK9h>GS;a8dM)>~^k_X>@ee>phQU%Dcp z`C#?YvgpH zJibOR9wwa#!u#=(e*TbfLYZ302LFHo;azB$Xr2@~95-jpxJNd{JXy9*c3(p(X1zAU zb$<87#QGNLBW8gX=IMGVejVGyn1_<}6o-53+3@^yf_}tX_DMrWD@Ei?``|>-WPWRra6Uwcb@VrXIW#l)+2w zNoJQcr*Ds*e9_0MJZk{7zm#BhuE*K=f3nH8$dAQqwC1-a_xc#VFir&DB$aNW8vg0I zsBv_}`j-c)RbM=r`}nZm>^iV*(iY_}&5;4O+!->xzGeHVFV*g|u&QrAxWCS~YVHpDAitdjQI@;ans`?VF5&t=t0ode$Av5O^+pZ_Da5 = ({ ...props }) => { + const { i18n } = useTranslation(); + + const lngs = ["en", "sv"]; + + const [settings, updateSettings] = useSettings(); + return ( + + {lngs.map((l) => ( + { + i18n.changeLanguage(l); + updateSettings({ preferedLanguage: l }); + }} + > + + {l} + + + ))} + + ); +}; diff --git a/i18n.ts b/i18n.ts new file mode 100644 index 00000000..fdcd0643 --- /dev/null +++ b/i18n.ts @@ -0,0 +1,22 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; + +import en from "./translations/en.json"; +import sv from "./translations/sv.json"; +import { getLocales } from "expo-localization"; + +i18n.use(initReactI18next).init({ + compatibilityJSON: "v3", + resources: { + en: { translation: en }, + sv: { translation: sv }, + }, + + lng: getLocales()[0].languageCode || "en", + fallbackLng: "en", + interpolation: { + escapeValue: false, + }, +}); + +export default i18n; diff --git a/package.json b/package.json index e22b8c20..1790ff55 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "expo-image": "~1.12.13", "expo-keep-awake": "~13.0.2", "expo-linking": "~6.3.1", + "expo-localization": "~15.0.3", "expo-navigation-bar": "~3.0.7", "expo-router": "~3.5.23", "expo-screen-orientation": "~7.0.5", @@ -51,11 +52,13 @@ "expo-updates": "~0.25.22", "expo-web-browser": "~13.0.3", "ffmpeg-kit-react-native": "^6.0.2", + "i18next": "^23.13.0", "jotai": "^2.9.1", "lodash": "^4.17.21", "nativewind": "^2.0.11", "react": "18.2.0", "react-dom": "18.2.0", + "react-i18next": "^15.0.1", "react-native": "0.74.5", "react-native-circular-progress": "^1.4.0", "react-native-compressor": "^1.8.25", diff --git a/translations/en.json b/translations/en.json new file mode 100644 index 00000000..be06bf72 --- /dev/null +++ b/translations/en.json @@ -0,0 +1,38 @@ +{ + "login": { + "username_required": "Username is required", + "error_title": "Error", + "url_error_message": "URL needs to start with http or https.", + "login": "Log in", + "login_subtitle": "Log in to any user account", + "username_placeholder": "Username", + "password_placeholder": "Password", + "login_button": "Log in" + }, + "server": { + "server_label": "Server: {{serverURL}}", + "change_server": "Change server", + "connect_to_server": "Connect to your Jellyfin server", + "server_url_placeholder": "Server URL", + "server_url_hint": "Server URL requires http or https", + "connect_button": "Connect" + }, + "home": { + "home": "Home", + "noInternet": "No Internet", + "noInternetMessage": "No worries, you can still watch\ndownloaded content.", + "goToDownloads": "Go to downloads", + "oops": "Oops!", + "errorMessage": "Something went wrong.\nPlease log out and in again.", + "continueWatching": "Continue Watching", + "nextUp": "Next Up", + "recentlyAddedMovies": "Recently Added in Movies", + "recentlyAddedTVShows": "Recently Added in TV-Shows", + "suggestions": "Suggestions" + }, + "tabs": { + "home": "Home", + "search": "Search", + "library": "Library" + } +} diff --git a/translations/sv.json b/translations/sv.json new file mode 100644 index 00000000..75b6ea2c --- /dev/null +++ b/translations/sv.json @@ -0,0 +1,38 @@ +{ + "login": { + "username_required": "Användarnamn krävs", + "error_title": "Fel", + "url_error_message": "URL måste börja med http eller https.", + "login_title": "Logga in", + "login_subtitle": "Logga in på ett användarkonto", + "username_placeholder": "Användarnamn", + "password_placeholder": "Lösenord", + "login_button": "Logga in" + }, + "server": { + "server_label": "Server: {{serverURL}}", + "change_server": "Byt server", + "connect_to_server": "Anslut till din Jellyfin-server", + "server_url_placeholder": "Server URL", + "server_url_hint": "Server URL kräver http eller https", + "connect_button": "Anslut" + }, + "home": { + "home": "Hem", + "noInternet": "Ingen Internet", + "noInternetMessage": "Ingen fara, du kan fortfarande titta\npå nedladdat innehåll.", + "goToDownloads": "Gå till nedladdningar", + "oops": "Hoppsan!", + "errorMessage": "Något gick fel.\nLogga ut och in igen.", + "continueWatching": "Fortsätt titta", + "nextUp": "Nästa upp", + "recentlyAddedMovies": "Nyligen tillagt i Filmer", + "recentlyAddedTVShows": "Nyligen tillagt i TV-Serier", + "suggestions": "Förslag" + }, + "tabs": { + "home": "Hem", + "search": "Sök", + "library": "Bibliotek" + } +} diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index eabf9284..df76f0a3 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -1,6 +1,7 @@ import { atom, useAtom } from "jotai"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useEffect } from "react"; +import { getLocales } from "expo-localization"; type Settings = { autoRotate?: boolean; @@ -10,6 +11,7 @@ type Settings = { deviceProfile?: "Expo" | "Native" | "Old"; forceDirectPlay?: boolean; mediaListCollectionIds?: string[]; + preferedLanguage?: string; }; /** @@ -33,6 +35,7 @@ const loadSettings = async (): Promise => { deviceProfile: "Expo", forceDirectPlay: false, mediaListCollectionIds: [], + preferedLanguage: getLocales()[0] || "en", }; }; From 9b4590c876e67a72867f3a4b26ccd53e2f5f18f9 Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Mon, 30 Dec 2024 20:06:56 -0500 Subject: [PATCH 02/34] Update Current Translated Messages with UI Changes --- app/(auth)/(tabs)/(home)/index.tsx | 2 +- app/login.tsx | 9 +++++---- translations/en.json | 10 ++-------- translations/sv.json | 8 +------- utils/atoms/settings.ts | 2 +- 5 files changed, 10 insertions(+), 21 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index b11e12f4..41e358fc 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -215,7 +215,7 @@ export default function index() { const latestMediaViews = collections.map((c) => { const includeItemTypes: BaseItemKind[] = c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; - const title = t("recentlyAdded" + c.Name); + const title = t("home.recentlyAddedIn", {libraryName: c.Name}); const queryKey = [ "home", "recentlyAddedIn" + c.CollectionType, diff --git a/app/login.tsx b/app/login.tsx index 1e0b2813..1ef2dd4a 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -22,12 +22,13 @@ import { import { z } from "zod"; +const { t, i18n } = useTranslation(); + 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 { t, i18n } = useTranslation(); const { setServer, login, removeServer, initiateQuickConnect } = useJellyfin(); const [api] = useAtom(apiAtom); @@ -186,7 +187,7 @@ const Login: React.FC = () => { ]); } } catch (error) { - Alert.alert("Error", "Failed to initiate Quick Connect"); + Alert.alert(t("login.error_title"), "Failed to initiate Quick Connect"); } }; @@ -201,7 +202,7 @@ const Login: React.FC = () => { - {t("login.login_button")} + {t("login.login_title")} <> {serverName ? ( <> diff --git a/translations/en.json b/translations/en.json index bb8e1797..0c3aaf32 100644 --- a/translations/en.json +++ b/translations/en.json @@ -2,16 +2,12 @@ "login": { "username_required": "Username is required", "error_title": "Error", - "url_error_message": "URL needs to start with http or https.", - "login": "Log in", - "login_subtitle": "Log in to any user account", + "login_title": "Log in", "username_placeholder": "Username", "password_placeholder": "Password", "login_button": "Log in" }, "server": { - "server_label": "Server: {{serverURL}}", - "change_server": "Change server", "enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server", "server_url_placeholder": "Server URL", "server_url_hint": "Make sure to include http or https", @@ -26,11 +22,9 @@ "errorMessage": "Something went wrong.\nPlease log out and in again.", "continueWatching": "Continue Watching", "nextUp": "Next Up", - "recentlyAddedMovies": "Recently Added in Movies", - "recentlyAddedSeries": "Recently Added in Series", + "recentlyAddedIn": "Recently Added in {{libraryName}}", "suggestedMovies": "Suggested Movies", "suggestedEpisodes": "Suggested Episodes" - }, "tabs": { "home": "Home", diff --git a/translations/sv.json b/translations/sv.json index 45a61554..ef3e5462 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -2,16 +2,12 @@ "login": { "username_required": "Användarnamn krävs", "error_title": "Fel", - "url_error_message": "URL måste börja med http eller https.", "login_title": "Logga in", - "login_subtitle": "Logga in på ett användarkonto", "username_placeholder": "Användarnamn", "password_placeholder": "Lösenord", "login_button": "Logga in" }, "server": { - "server_label": "Server: {{serverURL}}", - "change_server": "Byt server", "server_url_placeholder": "Server URL", "server_url_hint": "Server URL kräver http eller https", "connect_button": "Anslut" @@ -25,9 +21,7 @@ "errorMessage": "Något gick fel.\nLogga ut och in igen.", "continueWatching": "Fortsätt titta", "nextUp": "Nästa upp", - "recentlyAddedMovies": "Nyligen tillagt i Filmer", - "recentlyAddedTVShows": "Nyligen tillagt i TV-Serier", - "suggestions": "Förslag" + "recentlyAddedIn": "Nyligen tillagt i {{libraryName}}" }, "tabs": { "home": "Hem", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index a430578c..74d133b5 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -99,7 +99,7 @@ const loadSettings = (): Settings => { usePopularPlugin: false, deviceProfile: "Expo", mediaListCollectionIds: [], - preferedLanguage: getLocales()[0] || "en", + preferedLanguage: getLocales()[0].languageCode || "en", searchEngine: "Jellyfin", marlinServerUrl: "", openInVLC: false, From c0b71eb73d832ea660ed0b8aed71e0e56e0bbcaa Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Mon, 30 Dec 2024 21:03:02 -0500 Subject: [PATCH 03/34] Revert login message --- app/login.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/login.tsx b/app/login.tsx index 1ef2dd4a..a67b3d01 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -21,14 +21,11 @@ import { } from "react-native"; import { z } from "zod"; - -const { t, i18n } = useTranslation(); - const CredentialsSchema = z.object({ - username: z.string().min(1, t("login.username_required")), -}); + username: z.string().min(1, "Username is required"),}); const Login: React.FC = () => { + const { t } = useTranslation(); const { setServer, login, removeServer, initiateQuickConnect } = useJellyfin(); const [api] = useAtom(apiAtom); From 53b5fdda8765b9dfe97ab30e61695771b2fad53a Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Mon, 30 Dec 2024 21:13:52 -0500 Subject: [PATCH 04/34] fix import --- app/login.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/login.tsx b/app/login.tsx index a67b3d01..5c6b0c78 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -10,7 +10,6 @@ import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import React, { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; import { Alert, KeyboardAvoidingView, @@ -21,11 +20,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 { t } = useTranslation(); + const Login: React.FC = () => { const { setServer, login, removeServer, initiateQuickConnect } = useJellyfin(); const [api] = useAtom(apiAtom); From 4f623910279c949010fd2cf73ff9a9e1c1a9fa46 Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Mon, 30 Dec 2024 21:38:42 -0500 Subject: [PATCH 05/34] Add fr, search translation, fix login title --- app/(auth)/(tabs)/(home)/index.tsx | 2 +- app/(auth)/(tabs)/(search)/_layout.tsx | 4 ++- app/(auth)/(tabs)/(search)/index.tsx | 6 ++-- app/login.tsx | 7 ++--- components/LanguageSwitcher.tsx | 2 +- i18n.ts | 2 ++ translations/en.json | 7 +++++ translations/fr.json | 42 ++++++++++++++++++++++++++ 8 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 translations/fr.json diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 41e358fc..a1794fc9 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -56,7 +56,7 @@ type Section = ScrollingCollectionListSection | MediaListSection; export default function index() { const router = useRouter(); - const { i18n, t } = useTranslation(); + const { t } = useTranslation(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); diff --git a/app/(auth)/(tabs)/(search)/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx index 2917f1da..f8a2f168 100644 --- a/app/(auth)/(tabs)/(search)/_layout.tsx +++ b/app/(auth)/(tabs)/(search)/_layout.tsx @@ -1,8 +1,10 @@ import {commonScreenOptions, 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 ( { @@ -283,7 +285,7 @@ export default function search() { autoCorrect={false} returnKeyType="done" keyboardType="web-search" - placeholder="Search here..." + placeholder={t("search.search_hint")} value={search} onChangeText={(text) => setSearch(text)} /> @@ -462,7 +464,7 @@ export default function search() { ) : noResults && debouncedSearch.length > 0 ? ( - No results found for + {t("search.no_results_found_for")} "{debouncedSearch}" diff --git a/app/login.tsx b/app/login.tsx index 5c6b0c78..fffd8368 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -198,14 +198,13 @@ const CredentialsSchema = z.object({ - {t("login.login_title")} <> {serverName ? ( <> - {" to "} + {t("login.login_to_title") + " "} {serverName} - ) : null} + ) : t("login.login_title")} {serverURL} @@ -251,7 +250,7 @@ const CredentialsSchema = z.object({ onPress={handleQuickConnect} className="w-full mb-2" > - Use Quick Connect + {t("login.use_quick_connect")} - - + + + diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 556ae8c7..dc397a10 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -22,22 +22,24 @@ import { Button } from "../Button"; import { Image } from "expo-image"; import { useMemo } from "react"; import { storage } from "@/utils/mmkv"; +import { useTranslation } from "react-i18next"; interface Props extends ViewProps {} export const ActiveDownloads: React.FC = ({ ...props }) => { const { processes } = useDownload(); + const { t } = useTranslation(); if (processes?.length === 0) return ( - Active download - No active downloads + {t("home.downloads.active_download")} + {t("home.downloads.no_active_downloads")} ); return ( - Active downloads + {t("home.downloads.active_downloads")} {processes?.map((p) => ( diff --git a/translations/en.json b/translations/en.json index f6f45a1d..30927e3d 100644 --- a/translations/en.json +++ b/translations/en.json @@ -26,7 +26,26 @@ "nextUp": "Next Up", "recentlyAddedIn": "Recently Added in {{libraryName}}", "suggestedMovies": "Suggested Movies", - "suggestedEpisodes": "Suggested Episodes" + "suggestedEpisodes": "Suggested Episodes", + "settings": { + "settings_title": "Settings" + }, + "downloads": { + "downloads_title": "Downloads", + "tvseries": "TV-Series", + "movies": "Movies", + "queue": "Queue", + "queue_hint": "Queue and downloads will be lost on app restart", + "no_items_in_queue": "No items in queue", + "no_downloaded_items": "No downloaded items", + "delete_all_movies_button": "Delete all Movies", + "delete_all_tvseries_button": "Delete all TV-Series", + "delete_button": "Delete all", + "active_download": "Active download", + "no_active_downloads": "No active downloads", + "active_downloads": "Active downloads", + "toasts": {} + } }, "search": { "search_title": "Search", diff --git a/translations/fr.json b/translations/fr.json index a913d3ac..c480332a 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -26,7 +26,26 @@ "nextUp": "À suivre", "recentlyAddedIn": "Ajoutés récemment dans {{libraryName}}", "suggestedMovies": "Films suggérés", - "suggestedEpisodes": "Épisodes suggérés" + "suggestedEpisodes": "Épisodes suggérés", + "settings": { + "settings_title": "Paramètres" + }, + "downloads": { + "downloads_title": "Téléchargements", + "tvseries": "Séries TV", + "movies": "Films", + "queue": "File d'attente", + "queue_hint": "La file d'attente et les téléchargements seront perdus au redémarrage de l'application", + "no_items_in_queue": "Aucun item dans la file d'attente", + "no_downloaded_items": "Aucun item téléchargé", + "delete_all_movies_button": "Supprimer tous les films", + "delete_all_tvseries_button": "Supprimer toutes les séries", + "delete_all_button": "Supprimer tout", + "active_download": "Téléchargement actif", + "no_active_downloads": "Aucun téléchargements actifs", + "active_downloads": "Téléchargements actifs", + "toasts": {} + } }, "search": { "search_title": "Recherche", From f2eadabf6a4e88511ec9897ae40a351005795bd2 Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Tue, 31 Dec 2024 13:52:58 -0500 Subject: [PATCH 09/34] bump libs versions --- bun.lockb | Bin 592303 -> 592702 bytes package.json | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lockb b/bun.lockb index 902ab685a77f252c936d4e4cb3c0b7c9bc99a386..3615fd3834d30d6d36c2f08aaf1559eed8c74684 100755 GIT binary patch delta 20868 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

^?vK*o6cL--h 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 delta 20790 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 D{hIQa diff --git a/package.json b/package.json index 6b9a6e1d..ba949384 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "expo-keep-awake": "~13.0.2", "expo-linear-gradient": "~13.0.2", "expo-linking": "~6.3.1", - "expo-localization": "~15.0.3", + "expo-localization": "~16.0.0", "expo-network": "~6.0.1", "expo-notifications": "~0.28.19", "expo-router": "~3.5.24", @@ -66,13 +66,13 @@ "expo-web-browser": "~13.0.3", "ffmpeg-kit-react-native": "^6.0.2", "install": "^0.13.0", - "i18next": "^23.13.0", + "i18next": "^24.2.0", "jotai": "^2.10.1", "lodash": "^4.17.21", "nativewind": "^2.0.11", "react": "18.2.0", "react-dom": "18.2.0", - "react-i18next": "^15.0.1", + "react-i18next": "^15.4.0", "react-native": "0.74.5", "react-native-awesome-slider": "^2.5.6", "react-native-bottom-tabs": "0.7.1", From 946de97580455661f5f662cc8fa7890dd893c586 Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Tue, 31 Dec 2024 14:39:04 -0500 Subject: [PATCH 10/34] Remove LanguageSwitcher --- app/login.tsx | 2 -- components/LanguageSwitcher.tsx | 36 --------------------------------- 2 files changed, 38 deletions(-) delete mode 100644 components/LanguageSwitcher.tsx diff --git a/app/login.tsx b/app/login.tsx index fffd8368..f335bb79 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -1,7 +1,6 @@ import { Button } from "@/components/Button"; import { Input } from "@/components/common/Input"; import { Text } from "@/components/common/Text"; -import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { Ionicons } from "@expo/vector-icons"; import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; @@ -296,7 +295,6 @@ const CredentialsSchema = z.object({ {t("server.server_url_hint")} - */} - User Info + {t("home.settings.user_info.user_info_title")} - - - + + + - Quick connect + {t("home.settings.quick_connect.quick_connect_title")} - Storage + {t("home.settings.storage.storage_title")} - {size && App usage: {size.app.bytesToReadable()}} + {size && {t("home.settings.storage.app_usage", {usedSpace: size.app.bytesToReadable()})}} {size && ( - Available: {size.remaining?.bytesToReadable()}, Total:{" "} - {size.total?.bytesToReadable()} + {t("home.settings.storage.available_total", {availableSpace: size.remaining?.bytesToReadable(), totalSpace: size.total?.bytesToReadable()})} + {} )} - Logs + {t("home.settings.logs.logs_title")} {logs?.map((log, index) => ( @@ -167,7 +170,7 @@ export default function settings() { ))} {logs?.length === 0 && ( - No logs available + {t("home.settings.logs.no_logs_available")} )} diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx index ec9d71ce..d69f687d 100644 --- a/components/settings/AudioToggles.tsx +++ b/components/settings/AudioToggles.tsx @@ -3,6 +3,7 @@ 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"; interface Props extends ViewProps {} @@ -10,12 +11,13 @@ export const AudioToggles: React.FC = ({ ...props }) => { const media = useMedia(); const { settings, updateSettings } = media; const cultures = media.cultures; + const { t } = useTranslation(); if (!settings) return null; return ( - Audio + {t("home.settings.audio.audio_title")} = ({ ...props }) => { `} > - Audio language + {t("home.settings.audio.audio_language")} - Choose a default audio language. + {t("home.settings.audio.audio_language_hint")} @@ -76,9 +78,9 @@ export const AudioToggles: React.FC = ({ ...props }) => { - Use Default Audio + {t("home.settings.audio.use_default_audio")} - Play default audio track regardless of language. + {t("home.settings.audio.use_default_audio_hint")} = ({ ...props }) => { - Set Audio Track From Previous Item + {t("home.settings.audio.set_audio_track")} - Try to set the audio track to the closest match to the last - video. + {t("home.settings.audio.set_audio_track_hint")} { const { @@ -20,6 +21,8 @@ export const JellyseerrSettings = () => { clearAllJellyseerData, } = useJellyseerr(); + const { t } = useTranslation(); + const [user] = useAtom(userAtom); const [settings, updateSettings] = useSettings(); @@ -121,19 +124,16 @@ export const JellyseerrSettings = () => { ) : ( - This integration is in its early stages. Expect things to change. + {t("home.settings.jellyseer.jellyseer_warning")} - Server URL + {t("home.settings.jellyseer.server_url")} - Example: http(s)://your-host.url - - - (add port if required) + {t("home.settings.jellyseer.server_url_hint")} { marginBottom: 8, }} > - {promptForJellyseerrPass ? "Clear" : "Save"} + {promptForJellyseerrPass ? t("home.settings.jellyseer.clear_button") : t("home.settings.jellyseer.save_button")} { opacity: promptForJellyseerrPass ? 1 : 0.5, }} > - Password + {t("home.settings.jellyseer.password")} { className="h-12 mt-2" onPress={() => loginToJellyseerrMutation.mutate()} > - Login + {t("home.settings.jellyseer.login_button")} diff --git a/components/settings/MediaToggles.tsx b/components/settings/MediaToggles.tsx index c92902e2..6ef4cae4 100644 --- a/components/settings/MediaToggles.tsx +++ b/components/settings/MediaToggles.tsx @@ -1,17 +1,19 @@ import { useSettings } from "@/utils/atoms/settings"; import { TouchableOpacity, View, ViewProps } from "react-native"; import { Text } from "../common/Text"; +import { useTranslation } from "react-i18next"; interface Props extends ViewProps {} export const MediaToggles: React.FC = ({ ...props }) => { const [settings, updateSettings] = useSettings(); + const { t } = useTranslation(); if (!settings) return null; return ( - Media + {t("home.settings.media.media_title")} = ({ ...props }) => { `} > - Forward skip length + {t("home.settings.media.forward_skip_length")} - Choose length in seconds when skipping in video playback. + {t("home.settings.media.forward_skip_length_hint")} @@ -57,9 +59,9 @@ export const MediaToggles: React.FC = ({ ...props }) => { `} > - Rewind length + {t("home.settings.media.rewind_length")} - Choose length in seconds when skipping in video playback. + {t("home.settings.media.rewind_length_hint")} diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index a83f95f8..ef3ae116 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -43,6 +43,7 @@ import { AudioToggles } from "./AudioToggles"; import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; import { ListItem } from "@/components/ListItem"; import { JellyseerrSettings } from "./Jellyseerr"; +import { useTranslation } from "react-i18next"; interface Props extends ViewProps {} @@ -59,6 +60,8 @@ export const SettingToggles: React.FC = ({ ...props }) => { const queryClient = useQueryClient(); + const { t } = useTranslation(); + /******************** * Background task *******************/ @@ -137,15 +140,14 @@ export const SettingToggles: React.FC = ({ ...props }) => { - Other + {t("home.settings.other.other_title")} - Auto rotate + {t("home.settings.other.auto_rotate")} - Important on android since the video player orientation is - locked to the app orientation. + {t("home.settings.other.auto_rotate_hint")} = ({ ...props }) => { `} > - Video orientation + {t("home.settings.other.video_orientation")} - Set the full screen video player orientation. + {t("home.settings.other.video_orientation_hint")} @@ -263,9 +265,9 @@ export const SettingToggles: React.FC = ({ ...props }) => { - Safe area in controls + {t("home.settings.other.safe_area_in_controls")} - Enable safe area in video player controls + {t("home.settings.other.safe_area_in_controls_hint")} = ({ ...props }) => { - Use popular lists plugin - Made by: lostb1t + {t("home.settings.other.use_popular_lists_plugin")} + {t("home.settings.other.use_popular_lists_plugin_hint")} { Linking.openURL( @@ -288,7 +290,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { ); }} > - More info + {t("home.settings.other.more_info")} = ({ ...props }) => { `} > - Search engine + {t("home.settings.other.search_engine")} - Choose the search engine you want to use. + {t("home.settings.other.search_engine_hint")} @@ -421,7 +423,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { }); }} > - Save + {t("home.settings.other.save_button")} @@ -436,10 +438,9 @@ export const SettingToggles: React.FC = ({ ...props }) => { - Show Custom Menu Links + {t("home.settings.other.show_custom_menu_links")} - Show custom menu links defined inside your Jellyfin web - config.json file + {t("home.settings.other.show_custom_menu_links_hint")} @@ -448,7 +449,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { ) } > - More info + {t("home.settings.other.more_info")} = ({ ...props }) => { - Downloads + {t("home.settings.downloads.downloads_title")} = ({ ...props }) => { `} > - Download method + {t("home.settings.downloads.download_method")} - Choose the download method to use. Optimized requires the - optimized server. + {t("home.settings.downloads.download_method_hint")} @@ -531,10 +531,9 @@ export const SettingToggles: React.FC = ({ ...props }) => { }`} > - Remux max download + {t("home.settings.downloads.remux_max_download")} - This is the total media you want to be able to download at the - same time. + {t("home.settings.downloads.remux_max_download_hint")} = ({ ...props }) => { }`} > - Auto download + {t("home.settings.downloads.auto_download")} - This will automatically download the media file when it's - finished optimizing on the server. + {t("home.settings.downloads.auto_download_hint")} = ({ ...props }) => { - Optimized versions server + {t("home.settings.downloads.optimized_versions_server")} - Set the URL for the optimized versions server for downloads. + {t("home.settings.downloads.optimized_versions_server_hint")} @@ -629,7 +627,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { } else toast.error("Could not connect"); }} > - Save + {t("home.settings.downloads.save_button")} diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index 93745df2..e5fcc54f 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -4,6 +4,7 @@ import { Text } from "../common/Text"; import { useMedia } from "./MediaContext"; import { Switch } from "react-native-gesture-handler"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; +import { useTranslation } from "react-i18next"; interface Props extends ViewProps {} @@ -11,6 +12,8 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { const media = useMedia(); const { settings, updateSettings } = media; const cultures = media.cultures; + const { t } = useTranslation(); + if (!settings) return null; const subtitleModes = [ @@ -23,7 +26,7 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { return ( - Subtitle + {t("home.settings.subtitles.subtitle_title")} = ({ ...props }) => { `} > - Subtitle language + {t("home.settings.subtitles.subtitle_language")} - Choose a default subtitle language. + {t("home.settings.subtitles.subtitle_language_hint")} @@ -88,11 +91,9 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { `} > - Subtitle Mode + {t("home.settings.subtitles.subtitle_mode")} - Subtitles are loaded based on the default and forced flags in the - embedded metadata. Language preferences are considered when - multiple options are available. + {t("home.settings.subtitles.subtitle_mode_hint")} @@ -131,11 +132,10 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { - Set Subtitle Track From Previous Item + {t("home.settings.subtitles.set_subtitle_track")} - Try to set the subtitle track to the closest match to the last - video. + {t("home.settings.subtitles.set_subtitle_track_hint")} = ({ ...props }) => { `} > - Subtitle Size + {t("home.settings.subtitles.subtitle_size")} - Choose a default subtitle size for direct play (only works for - some subtitle formats). + {t("home.settings.subtitles.subtitle_size_hint")} diff --git a/translations/en.json b/translations/en.json index ae1da178..337cf1fe 100644 --- a/translations/en.json +++ b/translations/en.json @@ -29,13 +29,16 @@ "suggested_episodes": "Suggested Episodes", "settings": { "settings_title": "Settings", - "user_info": "User Info", - "user": "User", - "server": "Server", - "token": "Token", - "log_out": "Log out", - "quick_connect": "Quick connect", - "authorize": "Authorize", + "user_info": { + "user_info_title": "User Info", + "user": "User", + "server": "Server", + "log_out_button": "Log out" + }, + "quick_connect": { + "quick_connect_title": "Quick connect", + "authorize_button": "Authorize" + }, "media":{ "media_title": "Media", "forward_skip_length": "Forward skip length", @@ -58,14 +61,15 @@ "subtitle_language_hint": "Choose a default subtitle language.", "subtitle_mode": "Subtitle Mode", "subtitle_mode_hint": "Subtitles are loaded based on the default and forced flags in the\nembedded metadata. Language preferences are considered when\nmultiple options are available.", - "set_subtitle_track": "Try to set the subtitle track to the closest match to the last\nvideo.", + "set_subtitle_track": "Set Subtitle Track From Previous Item", + "set_subtitle_track_hint" :"Try to set the subtitle track to the closest match to the last\nvideo.", "subtitle_size": "Subtitle Size", "subtitle_size_hint": "Choose a default subtitle size for direct play (only works for\nsome subtitle formats)." }, "other": { "other_title": "Other", "auto_rotate": "Auto rotate", - "auto_rotate_hint": "Important on android since the video player orientation is\nlocked to the app orientation.", + "auto_rotate_hint": "Important on android since the video player orientation is locked to the app orientation.", "video_orientation": "Video orientation", "video_orientation_hint": "Set the full screen video player orientation", "safe_area_in_controls": "Safe area in controls", @@ -76,18 +80,20 @@ "search_engine": "Search engine", "search_engine_hint": "Choose the search engine you want to use.", "show_custom_menu_links": "Show Custom Menu Links", - "show_custom_menu_links_hint": "Show custom menu links defined inside your Jellyfin web\nconfig.json file" + "show_custom_menu_links_hint": "Show custom menu links defined inside your Jellyfin web config.json file", + "save_button": "Save" }, "downloads": { "downloads_title": "Downloads", "download_method": "Download method", - "download_method_hint": "Choose the download method to use. Optimized requires the\noptimized server.", + "download_method_hint": "Choose the download method to use. Optimized requires the optimized server.", "remux_max_download": "Remux max download", - "remux_max_download_hint": "This is the total media you want to be able to download at the\nsame time.", + "remux_max_download_hint": "This is the total media you want to be able to download at the same time.", "auto_download": "Auto download", - "auto_download_hint": "This will automatically download the media file when it's\nfinished optimizing on the server.", + "auto_download_hint": "This will automatically download the media file when it's finished optimizing on the server.", "optimized_versions_server": "Optimized versions server", - "optimized_versions_server_hint": "TSet the URL for the optimized versions server for downloads." + "optimized_versions_server_hint": "Set the URL for the optimized versions server for downloads.", + "save_button": "Save" }, "jellyseer": { "jellyseer_warning": "This integration is in its early stages. Expect things to change.", @@ -95,7 +101,10 @@ "server_url_hint": "Example: http(s)://your-host.url\n(add port if required)", "server_url_placeholder": "Jellyseerr URL...", "password": "Password", - "password_placeholder": "Enter password for Jellyfin user {{username}}" + "password_placeholder": "Enter password for Jellyfin user {{username}}", + "save_button": "Save", + "clear_button": "Clear", + "login_button": "Login" }, "storage": { "storage_title": "Storage", @@ -107,9 +116,7 @@ "logs": { "logs_title": "Logs", "no_logs_available": "No logs available" - }, - "save_button": "Save", - "login_button": "Login" + } }, "downloads": { "downloads_title": "Downloads", From 40b84103904f3d2f392bdce6fad276657c13d29c Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 1 Jan 2025 11:25:02 +0100 Subject: [PATCH 14/34] feat: enable manually setting language in settings --- app/_layout.tsx | 4 +- bun.lockb | Bin 592702 -> 592814 bytes components/settings/AppLanguageSelector.tsx | 80 ++++++++++++++++++++ components/settings/SettingToggles.tsx | 59 +++++++++++---- i18n.ts | 8 +- translations/en.json | 15 +++- utils/atoms/settings.ts | 2 +- 7 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 components/settings/AppLanguageSelector.tsx diff --git a/app/_layout.tsx b/app/_layout.tsx index 8096a100..b12630a7 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -273,9 +273,9 @@ function Layout() { useEffect(() => { i18n.changeLanguage( - settings?.preferedLanguage || getLocales()[0].languageCode || "en" + settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en" ); - }, [settings]); + }, [settings?.preferedLanguage, i18n]); const appState = useRef(AppState.currentState); diff --git a/bun.lockb b/bun.lockb index 3615fd3834d30d6d36c2f08aaf1559eed8c74684..a93e42e7b04651a9145cc2f50b038acca2fcda66 100755 GIT binary patch delta 10874 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 AyZ`_I delta 10761 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 { + const [settings, updateSettings] = useSettings(); + + return ( + + + {t("home.settings.languages.title")} + + + + + {t("home.settings.languages.app_language")} + + + {t("home.settings.languages.app_language_description")} + + + + + + + {APP_LANGUAGES.find( + (l) => l.value === settings?.preferedLanguage + )?.label || t("home.settings.languages.system")} + + + + + + {t("home.settings.languages.title")} + + { + updateSettings({ + preferedLanguage: undefined, + }); + }} + > + + {t("home.settings.languages.system")} + + + {APP_LANGUAGES?.map((l) => ( + { + updateSettings({ + preferedLanguage: l.value, + }); + }} + > + {l.label} + + ))} + + + + + ); +}; diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index ef3ae116..d32b3b24 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -44,6 +44,7 @@ import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; import { ListItem } from "@/components/ListItem"; import { JellyseerrSettings } from "./Jellyseerr"; import { useTranslation } from "react-i18next"; +import { AppLanguageSelector } from "./AppLanguageSelector"; interface Props extends ViewProps {} @@ -133,6 +134,8 @@ export const SettingToggles: React.FC = ({ ...props }) => { */} + + @@ -140,12 +143,16 @@ export const SettingToggles: React.FC = ({ ...props }) => { - {t("home.settings.other.other_title")} + + {t("home.settings.other.other_title")} + - {t("home.settings.other.auto_rotate")} + + {t("home.settings.other.auto_rotate")} + {t("home.settings.other.auto_rotate_hint")} @@ -168,7 +175,9 @@ export const SettingToggles: React.FC = ({ ...props }) => { `} > - {t("home.settings.other.video_orientation")} + + {t("home.settings.other.video_orientation")} + {t("home.settings.other.video_orientation_hint")} @@ -265,7 +274,9 @@ export const SettingToggles: React.FC = ({ ...props }) => { - {t("home.settings.other.safe_area_in_controls")} + + {t("home.settings.other.safe_area_in_controls")} + {t("home.settings.other.safe_area_in_controls_hint")} @@ -281,8 +292,12 @@ export const SettingToggles: React.FC = ({ ...props }) => { - {t("home.settings.other.use_popular_lists_plugin")} - {t("home.settings.other.use_popular_lists_plugin_hint")} + + {t("home.settings.other.use_popular_lists_plugin")} + + + {t("home.settings.other.use_popular_lists_plugin_hint")} + { Linking.openURL( @@ -290,7 +305,9 @@ export const SettingToggles: React.FC = ({ ...props }) => { ); }} > - {t("home.settings.other.more_info")} + + {t("home.settings.other.more_info")} + = ({ ...props }) => { `} > - {t("home.settings.other.search_engine")} + + {t("home.settings.other.search_engine")} + {t("home.settings.other.search_engine_hint")} @@ -438,7 +457,9 @@ export const SettingToggles: React.FC = ({ ...props }) => { - {t("home.settings.other.show_custom_menu_links")} + + {t("home.settings.other.show_custom_menu_links")} + {t("home.settings.other.show_custom_menu_links_hint")} @@ -449,7 +470,9 @@ export const SettingToggles: React.FC = ({ ...props }) => { ) } > - {t("home.settings.other.more_info")} + + {t("home.settings.other.more_info")} + = ({ ...props }) => { - {t("home.settings.downloads.downloads_title")} + + {t("home.settings.downloads.downloads_title")} + = ({ ...props }) => { `} > - {t("home.settings.downloads.download_method")} + + {t("home.settings.downloads.download_method")} + {t("home.settings.downloads.download_method_hint")} @@ -531,7 +558,9 @@ export const SettingToggles: React.FC = ({ ...props }) => { }`} > - {t("home.settings.downloads.remux_max_download")} + + {t("home.settings.downloads.remux_max_download")} + {t("home.settings.downloads.remux_max_download_hint")} @@ -562,7 +591,9 @@ export const SettingToggles: React.FC = ({ ...props }) => { }`} > - {t("home.settings.downloads.auto_download")} + + {t("home.settings.downloads.auto_download")} + {t("home.settings.downloads.auto_download_hint")} diff --git a/i18n.ts b/i18n.ts index 841150e9..edfe7202 100644 --- a/i18n.ts +++ b/i18n.ts @@ -6,8 +6,14 @@ import fr from "./translations/fr.json"; import sv from "./translations/sv.json"; import { getLocales } from "expo-localization"; +export const APP_LANGUAGES = [ + { label: "English", value: "en" }, + { label: "Français", value: "fr" }, + { label: "Svenska", value: "sv" }, +]; + i18n.use(initReactI18next).init({ - compatibilityJSON: "v3", + compatibilityJSON: "v4", resources: { en: { translation: en }, fr: { translation: fr }, diff --git a/translations/en.json b/translations/en.json index 337cf1fe..50f4b1bf 100644 --- a/translations/en.json +++ b/translations/en.json @@ -33,18 +33,19 @@ "user_info_title": "User Info", "user": "User", "server": "Server", - "log_out_button": "Log out" + "log_out_button": "Log out", + "token": "Token" }, "quick_connect": { "quick_connect_title": "Quick connect", "authorize_button": "Authorize" }, - "media":{ + "media": { "media_title": "Media", "forward_skip_length": "Forward skip length", "forward_skip_length_hint": "Choose length in seconds when skipping in video playback.", "rewind_length": "Rewind length", - "rewind_length_hint": "Choose length in seconds when skipping in video playback." + "rewind_length_hint": "Choose length in seconds when skipping in video playback." }, "audio": { "audio_title": "Audio", @@ -62,7 +63,7 @@ "subtitle_mode": "Subtitle Mode", "subtitle_mode_hint": "Subtitles are loaded based on the default and forced flags in the\nembedded metadata. Language preferences are considered when\nmultiple options are available.", "set_subtitle_track": "Set Subtitle Track From Previous Item", - "set_subtitle_track_hint" :"Try to set the subtitle track to the closest match to the last\nvideo.", + "set_subtitle_track_hint": "Try to set the subtitle track to the closest match to the last\nvideo.", "subtitle_size": "Subtitle Size", "subtitle_size_hint": "Choose a default subtitle size for direct play (only works for\nsome subtitle formats)." }, @@ -116,6 +117,12 @@ "logs": { "logs_title": "Logs", "no_logs_available": "No logs available" + }, + "languages": { + "title": "Languages", + "app_language": "App language", + "app_language_description": "Select the language for the app.", + "system": "System" } }, "downloads": { diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 74d133b5..2e6675ba 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -99,7 +99,7 @@ const loadSettings = (): Settings => { usePopularPlugin: false, deviceProfile: "Expo", mediaListCollectionIds: [], - preferedLanguage: getLocales()[0].languageCode || "en", + preferedLanguage: undefined, searchEngine: "Jellyfin", marlinServerUrl: "", openInVLC: false, From 34fc26ed180509754ace97cbf9e3a38e8c9f56f6 Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Wed, 1 Jan 2025 20:29:39 -0500 Subject: [PATCH 15/34] Quick connect alerts --- app/(auth)/(tabs)/(home)/settings.tsx | 10 +++++----- app/login.tsx | 10 +++++----- translations/en.json | 15 +++++++++++++-- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index f28918c6..cbe9871d 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -42,8 +42,8 @@ export default function settings() { const openQuickConnectAuthCodeInput = () => { Alert.prompt( - "Quick connect", - "Enter the quick connect code", + t("home.settings.quick_connect.quick_connect_title"), + t("home.settings.quick_connect.enter_the_quick_connect_code"), async (text) => { if (text) { try { @@ -55,14 +55,14 @@ export default function settings() { Haptics.notificationAsync( Haptics.NotificationFeedbackType.Success ); - Alert.alert("Success", "Quick connect authorized"); + Alert.alert(t("home.settings.quick_connect.success"), t("home.settings.quick_connect.quick_connect_autorized")); } else { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - Alert.alert("Error", "Invalid code"); + Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code")); } } catch (e) { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - Alert.alert("Error", "Invalid code"); + Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code")); } } } diff --git a/app/login.tsx b/app/login.tsx index f335bb79..dd9efe93 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -162,8 +162,8 @@ const CredentialsSchema = z.object({ 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; } @@ -175,14 +175,14 @@ const CredentialsSchema = z.object({ 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(t("login.error_title"), "Failed to initiate Quick Connect"); + Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect")); } }; diff --git a/translations/en.json b/translations/en.json index 50f4b1bf..4aac97be 100644 --- a/translations/en.json +++ b/translations/en.json @@ -7,7 +7,13 @@ "username_placeholder": "Username", "password_placeholder": "Password", "use_quick_connect": "Use Quick Connect", - "login_button": "Log in" + "login_button": "Log in", + "quick_connect": "Quick Connect", + "enter_code_to_login": "Enter code {{code}} to login", + "failed_to_initiate_quick_connect": "Failed to initiate Quick Connect", + "got_it": "Got it", + "connection_failed": "Connection failed", + "could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection." }, "server": { "enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server", @@ -38,7 +44,12 @@ }, "quick_connect": { "quick_connect_title": "Quick connect", - "authorize_button": "Authorize" + "authorize_button": "Authorize", + "enter_the_quick_connect_code": "Enter the Quick Connect code", + "success": "Success", + "quick_connect_autorized": "Quick Connect authorized", + "error": "Error", + "invalid_code": "Invalid code" }, "media": { "media_title": "Media", From e833b4bc68bed332570f92fd009151c62c9405be Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Wed, 1 Jan 2025 21:31:04 -0500 Subject: [PATCH 16/34] Alert and Toasts --- app/(auth)/(tabs)/(home)/downloads/index.tsx | 17 ++--- app/(auth)/(tabs)/(home)/settings.tsx | 2 +- app/(auth)/player/direct-player.tsx | 8 ++- app/(auth)/trailer/page.tsx | 4 +- components/DownloadItem.tsx | 8 ++- components/PlayButton.tsx | 6 +- components/downloads/ActiveDownloads.tsx | 7 +- components/series/JellyseerrSeasons.tsx | 7 +- components/settings/Jellyseerr.tsx | 18 +++--- components/settings/SettingToggles.tsx | 8 +-- hooks/useJellyseerr.ts | 15 +++-- hooks/useRemuxHlsToMp4.ts | 6 +- hooks/useWebsockets.ts | 4 +- providers/DownloadProvider.tsx | 32 +++++----- translations/en.json | 67 +++++++++++++++++++- 15 files changed, 143 insertions(+), 66 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index fd197600..b89e7cf6 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -17,6 +17,7 @@ import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomS import {toast} from "sonner-native"; import {writeToLog} from "@/utils/log"; import { useTranslation } from "react-i18next"; +import { t } from 'i18next'; export default function page() { const navigation = useNavigation(); @@ -68,16 +69,16 @@ export default function page() { }, [downloadedFiles]); 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()]) @@ -216,15 +217,15 @@ function migration_20241124() { const router = useRouter(); const { deleteAllFiles } = useDownload(); Alert.alert( - "New app version requires re-download", - "The new update reqires content to be downloaded again. Please remove all downloaded content and try again.", + t("home.downloads.new_app_version_requires_re_download"), + t("home.downloads.new_app_version_requires_re_download_description"), [ { - text: "Back", + text: t("home.downloads.back"), onPress: () => router.back(), }, { - text: "Delete", + text: t("home.downloads.delete"), style: "destructive", onPress: async () => await deleteAllFiles(), }, diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index cbe9871d..6c936c3e 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -75,7 +75,7 @@ export default function settings() { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } catch (e) { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - toast.error("Error deleting files"); + toast.error(t("home.settings.toasts.error_deleting_files")); } }; diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index e4b49320..a20f3d61 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -48,11 +48,13 @@ import { import { useSharedValue } from "react-native-reanimated"; import settings from "../(tabs)/(home)/settings"; import { useSettings } from "@/utils/atoms/settings"; +import { useTranslation } from "react-i18next"; export default function page() { const videoRef = useRef(null); const user = useAtomValue(userAtom); const api = useAtomValue(apiAtom); + const { t } = useTranslation(); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [showControls, _setShowControls] = useState(true); @@ -160,7 +162,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; } @@ -466,8 +468,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); }} diff --git a/app/(auth)/trailer/page.tsx b/app/(auth)/trailer/page.tsx index c2a84809..c172c8d0 100644 --- a/app/(auth)/trailer/page.tsx +++ b/app/(auth)/trailer/page.tsx @@ -3,10 +3,12 @@ import { useState, useCallback, useEffect, useMemo } from "react"; import { Button, Dimensions } from "react-native"; import { Alert, View } from "react-native"; import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe"; +import { useTranslation } from "react-i18next"; export default function page() { const searchParams = useGlobalSearchParams(); const navigation = useNavigation(); + const { t } = useTranslation(); console.log(searchParams); const { url } = searchParams as { url: string }; @@ -20,7 +22,7 @@ export default function page() { const onStateChange = useCallback((state: PLAYER_STATES) => { if (state === "ended") { setPlaying(false); - Alert.alert("video has finished playing!"); + Alert.alert(t("player.video_has_finished_playing")); } }, []); diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 4618bb4f..737ff6cc 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -32,6 +32,7 @@ import { MediaSourceSelector } from "./MediaSourceSelector"; import ProgressCircle from "./ProgressCircle"; import { RoundButton } from "./RoundButton"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; +import { useTranslation } from "react-i18next"; interface DownloadProps extends ViewProps { items: BaseItemDto[]; @@ -55,6 +56,7 @@ export const DownloadItems: React.FC = ({ const [user] = useAtom(userAtom); const [queue, setQueue] = useAtom(queueAtom); const [settings] = useSettings(); + const { t } = useTranslation(); const { processes, startBackgroundDownload, downloadedFiles } = useDownload(); const { startRemuxing } = useRemuxHlsToMp4(); @@ -160,7 +162,7 @@ export const DownloadItems: React.FC = ({ ); } } else { - toast.error("You are not allowed to download files."); + toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files")); } }, [ queue, @@ -212,8 +214,8 @@ export const DownloadItems: React.FC = ({ if (!res) { Alert.alert( - "Something went wrong", - "Could not get stream url from Jellyfin" + t("home.downloads.something_went_wrong"), + t("home.downloads.could_not_get_stream_url_from_jellyfin") ); continue; } diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index e5c5dd87..5a6b6dbf 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -33,6 +33,7 @@ import { Button } from "./Button"; import { SelectedOptions } from "./ItemContent"; import { chromecastProfile } from "@/utils/profiles/chromecast"; import * as Haptics from "expo-haptics"; +import { useTranslation } from "react-i18next"; interface Props extends React.ComponentProps { item: BaseItemDto; @@ -50,6 +51,7 @@ export const PlayButton: React.FC = ({ const { showActionSheetWithOptions } = useActionSheet(); const client = useRemoteMediaClient(); const mediaStatus = useMediaStatus(); + const { t } = useTranslation(); const [colorAtom] = useAtom(itemThemeColorAtom); const api = useAtomValue(apiAtom); @@ -131,8 +133,8 @@ export const PlayButton: React.FC = ({ if (!data?.url) { console.warn("No URL returned from getStreamUrl", data); Alert.alert( - "Client error", - "Could not create stream for Chromecast" + t("player.client_error"), + t("player.could_not_create_stream_for_chromecast") ); return; } diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index dc397a10..54e8ef59 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -22,13 +22,12 @@ import { Button } from "../Button"; import { Image } from "expo-image"; import { useMemo } from "react"; import { storage } from "@/utils/mmkv"; -import { useTranslation } from "react-i18next"; +import { t } from "i18next"; interface Props extends ViewProps {} export const ActiveDownloads: React.FC = ({ ...props }) => { const { processes } = useDownload(); - const { t } = useTranslation(); if (processes?.length === 0) return ( @@ -84,11 +83,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")); }, }); diff --git a/components/series/JellyseerrSeasons.tsx b/components/series/JellyseerrSeasons.tsx index f0efa418..52e80ccf 100644 --- a/components/series/JellyseerrSeasons.tsx +++ b/components/series/JellyseerrSeasons.tsx @@ -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"; const JellyseerrSeasonEpisodes: React.FC<{ details: TvDetails; @@ -155,13 +156,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, }, ]), diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx index f55dcb71..9543a664 100644 --- a/components/settings/Jellyseerr.tsx +++ b/components/settings/Jellyseerr.tsx @@ -50,7 +50,7 @@ export const JellyseerrSettings = () => { updateSettings({ jellyseerrServerUrl }); }, onError: () => { - toast.error("Failed to login"); + toast.error(t("jellyseerr.failed_to_login")); }, onSettled: () => { setJellyseerrPassword(undefined); @@ -124,16 +124,16 @@ export const JellyseerrSettings = () => { ) : ( - {t("home.settings.jellyseer.jellyseer_warning")} + {t("home.settings.jellyseerr.jellyseerr_warning")} - {t("home.settings.jellyseer.server_url")} + {t("home.settings.jellyseerr.server_url")} - {t("home.settings.jellyseer.server_url_hint")} + {t("home.settings.jellyseerr.server_url_hint")} { marginBottom: 8, }} > - {promptForJellyseerrPass ? t("home.settings.jellyseer.clear_button") : t("home.settings.jellyseer.save_button")} + {promptForJellyseerrPass ? t("home.settings.jellyseerr.clear_button") : t("home.settings.jellyseerr.save_button")} { opacity: promptForJellyseerrPass ? 1 : 0.5, }} > - {t("home.settings.jellyseer.password")} + {t("home.settings.jellyseerr.password")} { className="h-12 mt-2" onPress={() => loginToJellyseerrMutation.mutate()} > - {t("home.settings.jellyseer.login_button")} + {t("home.settings.jellyseerr.login_button")} diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index d32b3b24..02e2e799 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -77,10 +77,10 @@ export const SettingToggles: React.FC = ({ ...props }) => { if (settings?.autoDownload === true && !registered) { registerBackgroundFetchAsync(); - toast.success("Background downloads enabled"); + toast.success(t("home.settings.toasts.background_downloads_enabled")); } else if (settings?.autoDownload === false && registered) { unregisterBackgroundFetchAsync(); - toast.info("Background downloads disabled"); + toast.info(t("home.settings.toasts.background_downloads_disabled")); } else if (settings?.autoDownload === true && registered) { // Don't to anything } else if (settings?.autoDownload === false && !registered) { @@ -654,8 +654,8 @@ export const SettingToggles: React.FC = ({ ...props }) => { deviceId: await getOrSetDeviceId(), }); if (res) { - toast.success("Connected"); - } else toast.error("Could not connect"); + toast.success(t("home.settings.toasts.connected")); + } else toast.error(t("home.settings.toasts.could_not_connect")); }} > {t("home.settings.downloads.save_button")} diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index c0a8af22..95b00b12 100644 --- a/hooks/useJellyseerr.ts +++ b/hooks/useJellyseerr.ts @@ -28,6 +28,7 @@ import Issue from "@/utils/jellyseerr/server/entity/Issue"; import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes"; import { writeErrorLog } from "@/utils/log"; import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; +import { t } from "i18next"; interface SearchParams { query: string; @@ -113,7 +114,7 @@ export class JellyseerrApi { if (inRange(status, 200, 299)) { if (data.version < "2.0.0") { const error = - "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0"; + t("jellyseerr.toasts.jellyseer_does_not_meet_requirements"); toast.error(error); throw Error(error); } @@ -127,7 +128,7 @@ export class JellyseerrApi { requiresPass: true, }; } - toast.error(`Jellyseerr test failed. Please try again.`); + toast.error(t("jellyseerr.toasts.jellyseerr_test_failed")); writeErrorLog( `Jellyseerr returned a ${status} for url:\n` + response.config.url + @@ -140,7 +141,7 @@ export class JellyseerrApi { }; }) .catch((e) => { - const msg = "Failed to test jellyseerr server url"; + const msg = t("jellyseerr.toasts.failed_to_test_jellyseerr_server_url"); toast.error(msg); console.error(msg, e); return { @@ -259,7 +260,7 @@ export class JellyseerrApi { const issue = response.data; if (issue.status === IssueStatus.OPEN) { - toast.success("Issue submitted!"); + toast.success(t("jellyseerr.toasts.issue_submitted")); } return issue; }); @@ -342,13 +343,13 @@ export const useJellyseerr = () => { switch (mediaRequest.status) { case MediaRequestStatus.PENDING: case MediaRequestStatus.APPROVED: - toast.success(`Requested ${title}!`); + toast.success(t("jellyseerr.toasts.requested_item", {item: title})); break; case MediaRequestStatus.DECLINED: - toast.error(`You don't have permission to request!`); + toast.error(t("jellyseerr.toasts.you_dont_have_permission_to_request")); break; case MediaRequestStatus.FAILED: - toast.error(`Something went wrong requesting media!`); + toast.error(t("jellyseerr.toasts.something_went_wrong_requesting_media")); break; } }); diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index 25492e33..e3990965 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -18,6 +18,7 @@ import useDownloadHelper from "@/utils/download"; import { Api } from "@jellyfin/sdk"; import { useSettings } from "@/utils/atoms/settings"; import { JobStatus } from "@/utils/optimize-server"; +import { useTranslation } from "react-i18next"; const createFFmpegCommand = (url: string, output: string) => [ "-y", // overwrite output files without asking @@ -49,6 +50,7 @@ export const useRemuxHlsToMp4 = () => { const api = useAtomValue(apiAtom); const router = useRouter(); const queryClient = useQueryClient(); + const { t } = useTranslation(); const [settings] = useSettings(); const { saveImage } = useImageStorage(); @@ -84,7 +86,7 @@ export const useRemuxHlsToMp4 = () => { queryKey: ["downloadedItems"], }); saveDownloadedItemInfo(item, stat.getSize()); - toast.success("Download completed"); + toast.success(t("home.downloads.toasts.download_completed")); } setProcesses((prev) => { @@ -144,7 +146,7 @@ export const useRemuxHlsToMp4 = () => { // First lets save any important assets we want to present to the user offline await onSaveAssets(api, item); - toast.success(`Download started for ${item.Name}`, { + toast.success(t("home.downloads.toasts.download_started_for", {item: item.Name}), { action: { label: "Go to download", onClick: () => { diff --git a/hooks/useWebsockets.ts b/hooks/useWebsockets.ts index 75199b31..d9e6096a 100644 --- a/hooks/useWebsockets.ts +++ b/hooks/useWebsockets.ts @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { Alert } from "react-native"; import { useRouter } from "expo-router"; import { useWebSocketContext } from "@/providers/WebSocketProvider"; +import { useTranslation } from "react-i18next"; interface UseWebSocketProps { isPlaying: boolean; @@ -18,6 +19,7 @@ export const useWebSocket = ({ }: UseWebSocketProps) => { const router = useRouter(); const { ws } = useWebSocketContext(); + const { t } = useTranslation(); useEffect(() => { if (!ws) return; @@ -40,7 +42,7 @@ export const useWebSocket = ({ console.log("Command ~ DisplayMessage"); const title = json?.Data?.Arguments?.Header; const body = json?.Data?.Arguments?.Text; - Alert.alert("Message from server: " + title, body); + Alert.alert(t("player.message_from_server", {message: title}), body); } }; diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 706f96f5..eca1c1fb 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -50,6 +50,7 @@ import useDownloadHelper from "@/utils/download"; import { FileInfo } from "expo-file-system"; import * as Haptics from "expo-haptics"; import * as Application from "expo-application"; +import { useTranslation } from "react-i18next"; export type DownloadedItem = { item: Partial; @@ -68,6 +69,7 @@ const DownloadContext = createContext { router.push("/downloads"); toast.dismiss(); @@ -222,9 +224,9 @@ function useDownloadProvider() { }, }); - toast.info(`Download started for ${process.item.Name}`, { + toast.info(t("home.downloads.toasts.download_stated_for_item", {item: process.item.Name}), { action: { - label: "Go to downloads", + label: t("home.downloads.toasts.go_to_downloads"), onClick: () => { router.push("/downloads"); toast.dismiss(); @@ -273,10 +275,10 @@ function useDownloadProvider() { process.item, doneHandler.bytesDownloaded ); - toast.success(`Download completed for ${process.item.Name}`, { + toast.success(t("home.downloads.toasts.download_completed_for_item", {item: process.item.Name}), { duration: 3000, action: { - label: "Go to downloads", + label: t("home.downloads.toasts.go_to_downloads"), onClick: () => { router.push("/downloads"); toast.dismiss(); @@ -298,7 +300,7 @@ function useDownloadProvider() { if (error.errorCode === 404) { errorMsg = "File not found on server"; } - toast.error(`Download failed for ${process.item.Name} - ${errorMsg}`); + toast.error(t("home.downloads.toasts.download_failed_for_item", {item: process.item.Name, error: errorMsg})); writeToLog("ERROR", `Download failed for ${process.item.Name}`, { error, processDetails: { @@ -355,9 +357,9 @@ function useDownloadProvider() { throw new Error("Failed to start optimization job"); } - toast.success(`Queued ${item.Name} for optimization`, { + toast.success(t("home.downloads.toasts.queued_item_for_optimization", {item: item.Name}), { action: { - label: "Go to download", + label: t("home.downloads.toasts.go_to_downloads"), onClick: () => { router.push("/downloads"); toast.dismiss(); @@ -375,21 +377,21 @@ function useDownloadProvider() { headers: error.response?.headers, }); toast.error( - `Failed to start download for ${item.Name}: ${error.message}` + t("home.downloads.toasts.failed_to_start_download_for_item", {item: item.Name, message: error.message}) ); if (error.response) { toast.error( - `Server responded with status ${error.response.status}` + t("home.downloads.toasts.server_responded_with_status", {statusCode: error.response.status}) ); } else if (error.request) { - toast.error("No response received from server"); + t("home.downloads.toasts.no_response_received_from_server"); } else { toast.error("Error setting up the request"); } } else { console.error("Non-Axios error:", error); toast.error( - `Failed to start download for ${item.Name}: Unexpected error` + t("home.downloads.toasts.failed_to_start_download_for_item_unexpected_error", {item: item.Name}) ); } } @@ -405,11 +407,11 @@ function useDownloadProvider() { queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }), ]) .then(() => - toast.success("All files, folders, and jobs deleted successfully") + toast.success(t("home.downloads.toasts.all_files_folders_jobs_deleted")) ) .catch((reason) => { console.error("Failed to delete all files, folders, and jobs:", reason); - toast.error("An error occurred while deleting files and jobs"); + toast.error(t("home.downloads.toasts.an_error_occured_while_deleting_files_and_jobs")); }); }; diff --git a/translations/en.json b/translations/en.json index 4aac97be..17090a71 100644 --- a/translations/en.json +++ b/translations/en.json @@ -107,8 +107,8 @@ "optimized_versions_server_hint": "Set the URL for the optimized versions server for downloads.", "save_button": "Save" }, - "jellyseer": { - "jellyseer_warning": "This integration is in its early stages. Expect things to change.", + "jellyseerr": { + "jellyseerr_warning": "This integration is in its early stages. Expect things to change.", "server_url": "Server URL", "server_url_hint": "Example: http(s)://your-host.url\n(add port if required)", "server_url_placeholder": "Jellyseerr URL...", @@ -134,6 +134,13 @@ "app_language": "App language", "app_language_description": "Select the language for the app.", "system": "System" + }, + "toasts":{ + "error_deleting_files": "Error deleting files", + "background_downloads_enabled": "Background downloads enabled", + "background_downloads_disabled": "Background downloads disabled", + "connected": "Connected", + "could_not_connect": "Could not connect" } }, "downloads": { @@ -150,7 +157,36 @@ "active_download": "Active download", "no_active_downloads": "No active downloads", "active_downloads": "Active downloads", - "toasts": {} + "new_app_version_requires_re_download": "New app version requires re-download", + "new_app_version_requires_re_download_description": "The new update reqires content to be downloaded again. Please remove all downloaded content and try again.", + "back": "Back", + "delete": "Delete", + "something_went_wrong": "Something went wrong", + "could_not_get_stream_url_from_jellyfin": "Could not get stream URL from Jellyfin", + "toasts": { + "you_are_not_allowed_to_download_files": "You are not allowed to download files.", + "deleted_all_movies_successfully": "Deleted all movies successfully!", + "failed_to_delete_all_movies": "Failed to delete all movies", + "deleted_all_tvseries_successfully": "Deleted all TV-Series successfully!", + "failed_to_delete_all_tvseries": "Failed to delete all TV-Series", + "download_cancelled": "Download cancelled", + "could_not_cancel_download": "Could not cancel download", + "download_completed": "Download completed", + "download_started_for": "Download started for {{item}}", + "item_is_ready_to_be_downloaded": "{{item}} is ready to be downloaded", + "download_stated_for_item": "Download started for {{item}}", + "download_failed_for_item": "Download failed for {{item}} - {{error}}", + "download_completed_for_item": "Download completed for {{item}}", + "queued_item_for_optimization": "Queued {{item}} for optimization", + "failed_to_start_download_for_item": "Failed to start download for {{item}}: {{message}}", + "server_responded_with_status_code": "Server responded with status {{statusCode}}", + "no_response_received_from_server": "No response received from server", + "error_setting_up_the_request": "Error setting up the request", + "failed_to_start_download_for_item_unexpected_error": "Failed to start download for {{item}}: Unexpected error", + "all_files_folders_and_jobs_deleted_successfully": "All files, folders and jobs deleted successfully", + "an_error_occured_while_deleting_files_and_jobs": "An error occurred while deleting files and jobs", + "go_to_downloads": "Go to downloads" + } } }, "search": { @@ -164,6 +200,31 @@ "no_results": "No results", "no_libraries_found": "No libraries found" }, + "player": { + "error": "Error", + "failed_to_get_stream_url": "Failed to get stream URL", + "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", + "client_error": "Client error", + "could_not_create_stream_for_chromecast": "Could not create stream for Chromecast", + "message_from_server": "Message from server: {{message}}", + "video_has_finished_playing": "Video has finished playing!" + }, + "jellyseerr":{ + "confirm": "Confirm", + "cancel": "Cancel", + "yes": "Yes", + "are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?", + "failed_to_login": "Failed to login", + "toasts": { + "jellyseer_does_not_meet_requirements": "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0", + "jellyseerr_test_failed": "Jellyseerr test failed. Please try again.", + "failed_to_test_jellyseerr_server_url": "Failed to test jellyseerr server url", + "issue_submitted": "Issue submitted!", + "requested_item": "Requested {{item}}!", + "you_dont_have_permission_to_request": "You don't have permission to request!", + "something_went_wrong_requesting_media": "Something went wrong requesting media!" + } + }, "tabs": { "home": "Home", "search": "Search", From e23387a384394a5dd7eb93197323db93455750ad Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Wed, 1 Jan 2025 21:57:46 -0500 Subject: [PATCH 17/34] Library headers, filters and favorites --- app/(auth)/(tabs)/(favorites)/_layout.tsx | 4 ++- .../collections/[collectionId].tsx | 13 +++++---- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 10 +++---- app/(auth)/(tabs)/(libraries)/_layout.tsx | 18 ++++++------ app/(auth)/(tabs)/(search)/index.tsx | 4 +-- app/(auth)/(tabs)/_layout.tsx | 2 +- components/filters/FilterSheet.tsx | 4 ++- translations/en.json | 28 +++++++++++++++++-- translations/fr.json | 28 +++++++++++++++++-- 9 files changed, 81 insertions(+), 30 deletions(-) diff --git a/app/(auth)/(tabs)/(favorites)/_layout.tsx b/app/(auth)/(tabs)/(favorites)/_layout.tsx index f96cd516..cd0a619e 100644 --- a/app/(auth)/(tabs)/(favorites)/_layout.tsx +++ b/app/(auth)/(tabs)/(favorites)/_layout.tsx @@ -1,8 +1,10 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { Stack } from "expo-router"; import { Platform } from "react-native"; +import { useTranslation } from "react-i18next"; export default function SearchLayout() { + const { t } = useTranslation(); return ( { 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); @@ -244,7 +247,7 @@ const page: React.FC = () => { }} set={setSelectedGenres} values={selectedGenres} - title="Genres" + title={t("library.headers.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.headers.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.headers.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.headers.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.headers.sort_order")} renderItemLabel={(item) => sortOrderOptions.find((i) => i.key === item)?.value || "" } diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index a0cf2f6c..31142eef 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -302,7 +302,7 @@ const Page = () => { }} set={setSelectedGenres} values={selectedGenres} - title="Genres" + title={t("library.headers.genres")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase()) @@ -329,7 +329,7 @@ const Page = () => { }} set={setSelectedYears} values={selectedYears} - title="Years" + title={t("library.headers.years")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.includes(search)} /> @@ -354,7 +354,7 @@ const Page = () => { }} set={setSelectedTags} values={selectedTags} - title="Tags" + title={t("library.headers.tags")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase()) @@ -372,7 +372,7 @@ const Page = () => { queryFn={async () => sortOptions.map((s) => s.key)} set={setSortBy} values={sortBy} - title="Sort By" + title={t("library.headers.sort_by")} renderItemLabel={(item) => sortOptions.find((i) => i.key === item)?.value || "" } @@ -392,7 +392,7 @@ const Page = () => { queryFn={async () => sortOrderOptions.map((s) => s.key)} set={setSortOrder} values={sortOrder} - title="Sort Order" + title={t("library.headers.sort_order")} renderItemLabel={(item) => sortOrderOptions.find((i) => i.key === item)?.value || "" } diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx index 42626d43..a60b33bc 100644 --- a/app/(auth)/(tabs)/(libraries)/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx @@ -42,11 +42,11 @@ export default function IndexLayout() { side={"bottom"} sideOffset={10} > - Display + {t("library.options.display")} - Display + {t("library.options.display")} - Row + {t("library.options.row")} - List + {t("library.options.list")} - Image style + {t("library.options.image_style")} - Poster + {t("library.options.poster")} - Cover + {t("library.options.cover")} @@ -157,7 +157,7 @@ export default function IndexLayout() { > - Show titles + {t("library.options.show_titles")} - Show stats + {t("library.options.show_stats")} diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index ad9cae4b..4c90c264 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -129,7 +129,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); @@ -308,7 +308,7 @@ export default function search() { autoCorrect={false} returnKeyType="done" keyboardType="web-search" - placeholder={t("search.search_hint")} + placeholder={t("search.search_here")} value={search} onChangeText={(text) => setSearch(text)} /> diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index 39881192..17c7cb17 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -73,7 +73,7 @@ export default function TabLayout() { diff --git a/components/filters/FilterSheet.tsx b/components/filters/FilterSheet.tsx index fe6d9f6a..25486ce9 100644 --- a/components/filters/FilterSheet.tsx +++ b/components/filters/FilterSheet.tsx @@ -19,6 +19,7 @@ import { StyleSheet, TouchableOpacity, View, ViewProps } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { Button } from "../Button"; import { Input } from "../common/Input"; +import { useTranslation } from "react-i18next"; interface Props extends ViewProps { open: boolean; @@ -76,6 +77,7 @@ export const FilterSheet = ({ }: Props) => { const bottomSheetModalRef = useRef(null); const snapPoints = useMemo(() => ["80%"], []); + const { t } = useTranslation(); const [data, setData] = useState([]); const [offset, setOffset] = useState(0); @@ -156,7 +158,7 @@ export const FilterSheet = ({ {_data?.length} items {showSearch && ( { diff --git a/translations/en.json b/translations/en.json index 17090a71..7526a592 100644 --- a/translations/en.json +++ b/translations/en.json @@ -191,14 +191,35 @@ }, "search": { "search_title": "Search", - "search_hint": "Search here...", + "search_here": "Search here...", + "search": "Search...", "no_results_found_for": "No results found for" }, "library": { "library_title": "Library", "no_items_found": "No items found", "no_results": "No results", - "no_libraries_found": "No libraries found" + "no_libraries_found": "No libraries found", + "options": { + "display": "Display", + "row": "Row", + "list": "List", + "image_style": "Image style", + "poster": "Poster", + "cover": "Cover", + "show_titles": "Show titles", + "show_stats": "Show stats" + }, + "headers": { + "genres": "Genres", + "years": "Years", + "sort_by": "Sort By", + "sort_order": "Sort Order", + "tags": "Tags" + } + }, + "favorites": { + "favorites_title": "Favorites" }, "player": { "error": "Error", @@ -229,6 +250,7 @@ "home": "Home", "search": "Search", "library": "Library", - "custom_links": "Custom Links" + "custom_links": "Custom Links", + "favorites": "Favorites" } } diff --git a/translations/fr.json b/translations/fr.json index b0f96f2d..0e42690c 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -49,19 +49,41 @@ }, "search": { "search_title": "Recherche", - "search_hint": "Rechercher ici...", + "search_here": "Rechercher ici...", + "search": "Rechercher...", "no_results_found_for": "Aucun résultat trouvé pour" }, "library": { "library_title": "Bibliothèque", "no_items_found": "Aucun item trouvé", "no_results": "Aucun résultat", - "no_libraries_found": "Aucune bibliothèque trouvée" + "no_libraries_found": "Aucune bibliothèque trouvée", + "options": { + "display": "Affichage", + "row": "Rangée", + "list": "Liste", + "image_style": "Style d'image", + "poster": "Affiche", + "cover": "Couverture", + "show_titles": "Afficher les titres", + "show_stats": "Afficher les statistiques" + }, + "headers": { + "genres": "Genres", + "years": "Années", + "sort_by": "Trier par", + "sort_order": "Ordre de tri", + "tags": "Tags" + } + }, + "favorites": { + "favorites_title": "Favoris" }, "tabs": { "home": "Accueil", "search": "Recherche", "library": "Bibliothèque", - "custom_links": "Liens personalisés" + "custom_links": "Liens personalisés", + "favorites": "Favoris" } } From ed993d07cec6b7387d954753f814184474b8332a Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Fri, 3 Jan 2025 16:33:51 -0500 Subject: [PATCH 18/34] Types --- app/(auth)/(tabs)/(search)/index.tsx | 20 +-- components/home/Favorites.tsx | 17 +-- components/settings/Jellyseerr.tsx | 20 +-- providers/DownloadProvider.tsx | 2 +- translations/en.json | 31 +++- translations/fr.json | 204 ++++++++++++++++++++++++++- 6 files changed, 256 insertions(+), 38 deletions(-) diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 3c448e26..2d7afff8 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -348,7 +348,7 @@ export default function search() { {searchType === "Library" && ( <> m.Id!)} renderItem={(item: BaseItemDto) => ( m.Id!)} - header="Series" + header={t("search.series")} renderItem={(item: BaseItemDto) => ( m.Id!)} - header="Episodes" + header={t("search.episodes")} renderItem={(item: BaseItemDto) => ( m.Id!)} - header="Collections" + header={t("search.collections")} renderItem={(item: BaseItemDto) => ( m.Id!)} - header="Actors" + header={t("search.actors")} renderItem={(item: BaseItemDto) => ( m.Id!)} - header="Artists" + header={t("search.artists")} renderItem={(item: BaseItemDto) => ( m.Id!)} - header="Albums" + header={t("search.albums")} renderItem={(item: BaseItemDto) => ( m.Id!)} - header="Songs" + header={t("search.songs")} renderItem={(item: BaseItemDto) => ( ( )} /> ( diff --git a/components/home/Favorites.tsx b/components/home/Favorites.tsx index 6cf05109..908c3b3c 100644 --- a/components/home/Favorites.tsx +++ b/components/home/Favorites.tsx @@ -5,6 +5,7 @@ import { View } from "react-native"; import { ScrollingCollectionList } from "./ScrollingCollectionList"; import { useCallback } from "react"; import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client"; +import { t } from "i18next"; export const Favorites = () => { const [api] = useAtom(apiAtom); @@ -68,50 +69,50 @@ export const Favorites = () => { diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx index d75d8487..5a33efd5 100644 --- a/components/settings/Jellyseerr.tsx +++ b/components/settings/Jellyseerr.tsx @@ -93,34 +93,34 @@ export const JellyseerrSettings = () => { <> diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index e981921f..ba414bd8 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -407,7 +407,7 @@ function useDownloadProvider() { queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }), ]) .then(() => - toast.success(t("home.downloads.toasts.all_files_folders_jobs_deleted")) + toast.success(t("home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully")) ) .catch((reason) => { console.error("Failed to delete all files, folders, and jobs:", reason); diff --git a/translations/en.json b/translations/en.json index 92623026..75a884fe 100644 --- a/translations/en.json +++ b/translations/en.json @@ -98,7 +98,14 @@ "password_placeholder": "Enter password for Jellyfin user {{username}}", "save_button": "Save", "clear_button": "Clear", - "login_button": "Login" + "login_button": "Login", + "total_media_requests": "Total media requests", + "movie_quota_limit": "Movie quota limit", + "movie_quota_days": "Movie quota days", + "tv_quota_limit": "TV quota limit", + "tv_quota_days": "TV quota days", + "reset_jellyseerr_config_button": "Reset Jellyseerr config", + "unlimited": "Unlimited" }, "marlin_search": { "enable_marlin_search": "Enable Marlin Search ", @@ -193,7 +200,17 @@ "search_title": "Search", "search_here": "Search here...", "search": "Search...", - "no_results_found_for": "No results found for" + "no_results_found_for": "No results found for", + "movies": "Movies", + "series": "Series", + "episodes": "Episodes", + "collections": "Collections", + "actors": "Actors", + "artists": "Artists", + "albums": "Albums", + "songs": "Songs", + "requested_movies": "Requested Movies", + "requested_series": "Requested Series" }, "library": { "library_title": "Library", @@ -219,7 +236,15 @@ } }, "favorites": { - "favorites_title": "Favorites" + "favorites_title": "Favorites", + "series": "Series", + "movies": "Movies", + "episodes": "Episodes", + "videos": "Videos", + "boxsets": "Boxsets", + "playlists": "Playlists", + "music_albums": "Music Albums", + "audio": "Audio" }, "player": { "error": "Error", diff --git a/translations/fr.json b/translations/fr.json index 0e42690c..de858ac3 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -7,13 +7,21 @@ "username_placeholder": "Nom d'utilisateur", "password_placeholder": "Mot de passe", "use_quick_connect": "Utiliser Quick Connect", - "login_button": "Se connecter" + "login_button": "Se connecter", + "quick_connect": "Quick Connect", + "enter_code_to_login": "Entrez le code {{code}} pour vous connecter", + "failed_to_initiate_quick_connect": "Échec de l'initialisation de Quick Connect", + "got_it": "D'accord", + "connection_failed": "La connection a échouée", + "could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier l'URL et votre connection réseau." }, "server": { "enter_url_to_jellyfin_server": "Entrez l'URL de votre serveur Jellyfin", "server_url_placeholder": "URL du serveur", "server_url_hint": "Assurez-vous d'inclure http ou https", - "connect_button": "Connexion" + "connect_button": "Connexion", + "previous_servers": "Serveurs précédents", + "clear_button": "Effacer" }, "home": { "home": "Accueil", @@ -28,7 +36,119 @@ "suggested_movies": "Films suggérés", "suggested_episodes": "Épisodes suggérés", "settings": { - "settings_title": "Paramètres" + "settings_title": "Paramètres", + "log_out_button": "Déconnexion", + "user_info": { + "user_info_title": "Informations utilisateur", + "user": "Utilisateur", + "server": "Serveur", + "token": "Jeton", + "app_version": "Version de l'application" + }, + "quick_connect": { + "quick_connect_title": "Quick connect", + "authorize_button": "Autoriser Quick Connect", + "enter_the_quick_connect_code": "Entrez le code Quick Connect", + "success": "Succès", + "quick_connect_autorized": "Quick Connect autorisé", + "error": "Errur", + "invalid_code": "Code invalide" + }, + "media_controls": { + "media_controls_title": "Contrôles Média", + "forward_skip_length": "Durée de saut en avant", + "rewind_length": "Durée de retour arrière" + }, + "audio": { + "audio_title": "Audio", + "set_audio_track": "Configurer la piste audio à partir de l'élément précédent", + "audio_language": "Langue audio", + "audio_hint": "Chosissez une langue audio par défaut." + }, + "subtitles": { + "subtitle_title": "Sous-titres", + "subtitle_language": "Langue des sous-titres", + "subtitle_mode": "Mode des sous-titres", + "set_subtitle_track": "Configurer la piste de sous-titres à partir de l'élément précédent", + "subtitle_size": "Taille des sous-titres", + "subtitle_hint": "Configurez les préférences des sous-titres." + }, + "other": { + "other_title": "Autres", + "auto_rotate": "Rotation automatique", + "video_orientation": "Orientation vidéo", + "safe_area_in_controls": "Zone de sécurité dans les contrôles", + "show_custom_menu_links": "Afficher les liens personnalisés" + }, + "downloads": { + "downloads_title": "Téléchargements", + "download_method": "Méthode de téléchargement", + "remux_max_download": "Téléchargement max remux", + "auto_download": "Téléchargement automatique", + "optimized_versions_server": "Serveur de versions optimisées" + }, + "plugins": { + "plugins_title": "Plugiciels", + "jellyseerr": { + "jellyseerr_warning": "Cette intégration est dans ses débuts. Attendez-vous à ce que des choses changent.", + "server_url": "URL du serveur", + "server_url_hint": "Exemple: http(s)://votre-domaine.url\n(ajouter le port si nécessaire)", + "server_url_placeholder": "URL Jellyseerr...", + "password": "Mot de passe", + "password_placeholder": "Entrez le mot de passe pour l'utilisateur Jellyfin {{username}}", + "save_button": "Enregistrer", + "clear_button": "Effacer", + "login_button": "Connexion", + "total_media_requests": "Total de demandes de médias", + "movie_quota_limit": "Limite de quota de film", + "movie_quota_days": "Jours de quota de film", + "tv_quota_limit": "Limite de quota TV", + "tv_quota_days": "Jours de quota TV", + "reset_jellyseerr_config_button": "Réinitialiser la configuration Jellyseerr", + "unlimited": "Illimité" + }, + "marlin_search": { + "enable_marlin_search": "Activer Marlin Search ", + "url": "URL", + "server_url_placeholder": "http(s)://domaine.org:port", + "marlin_search_hint": "Entrez l'URL du serveur Marlin. L'URL devrait inclure http ou https et optionnellement le port.", + "read_more_about_marlin": "Lisez-en plus sur Marlin.", + "save_button": "Enregistrer" + }, + "popular_lists": { + "enable_plugin": "Activer le plugiciel", + "enable_popular_lists": "Activer Popular Lists", + "enable_popular_hint": "Popular Lists est un plugiciel qui affiche des listes populaires sur l'écran d'accueil.", + "read_more_about_popular_lists": "Lisez-en plus sur Popular Lists.", + "no_collections_found": "Aucune collection trouvée. Ajoutez-en dans Jellyfin.", + "select_the_lists_you_want_to_display": "Sélectionnez les listes que vous voulez afficher sur l'écran d'accueil." + } + }, + "storage": { + "storage_title": "Stockage", + "app_usage": "App {{usedSpace}}%", + "phone_usage": "Téléphone {{availableSpace}}%", + "size_used": "{{used}} de {{total}} utilisé", + "delete_all_downloaded_files": "Supprimer tous les fichiers téléchargés" + }, + "logs": { + "logs_title": "Journaux", + "no_logs_available": "Aucun journal disponible", + "delete_all_logs": "Supprimer tous les journaux" + }, + "languages": { + "title": "Langues", + "app_language": "Langue de l'application", + "app_language_description": "Sélectionnez la langue de l'application", + "system": "Système" + }, + "toasts":{ + "error_deleting_files": "Erreur lors de la suppression des fichiers", + "background_downloads_enabled": "Téléchargements en arrière-plan activés", + "background_downloads_disabled": "Téléchargements en arrière-plan désactivés", + "connected": "Connecté", + "could_not_connect": "Impossible de se connecter" + } }, "downloads": { "downloads_title": "Téléchargements", @@ -44,14 +164,53 @@ "active_download": "Téléchargement actif", "no_active_downloads": "Aucun téléchargements actifs", "active_downloads": "Téléchargements actifs", - "toasts": {} + "new_app_version_requires_re_download": "La nouvelle version de l'application nécessite un nouveau téléchargement", + "new_app_version_requires_re_download_description": "Une nouvelle version de l'application est disponible. Veuillez supprimer tous les téléchargements et redémarrer l'application pour télécharger à nouveau", + "back": "Retour", + "delete": "Supprimer", + "something_went_wrong": "Quelque chose s'est mal passé", + "could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin", + "toasts": { + "you_are_not_allowed_to_download_files": "Vous n'êtes pas autorisé à télécharger des fichiers", + "deleted_all_movies_successfully": "Tous les films ont été supprimés avec succès!", + "failed_to_delete_all_movies": "Échec de la suppression de tous les films", + "deleted_all_tvseries_successfully": "Toutes les séries ont été supprimées avec succès!", + "failed_to_delete_all_tvseries": "Échec de la suppression de toutes les séries", + "download_cancelled": "Téléchargement annulé", + "could_not_cancel_download": "Impossible d'annuler le téléchargement", + "download_completed": "Téléchargement terminé", + "download_started_for": "Téléchargement démarré pour {{item}}", + "item_is_ready_to_be_downloaded": "{{item}} est prêt à être téléchargé", + "download_stated_for_item": "Téléchargement démarré pour {{item}}", + "download_failed_for_item": "Échec du téléchargement pour {{item}} - {{error}}", + "download_completed_for_item": "Téléchargement terminé pour {{item}}", + "queued_item_for_optimization": "{{item}} mis en file d'attente pour l'optimisation", + "failed_to_start_download_for_item": "Échec du démarrage du téléchargement pour {{item}}: {{message}}", + "server_responded_with_status_code": "Le serveur a répondu avec le code de statut {{statusCode}}", + "no_response_received_from_server": "Aucune réponse reçue du serveur", + "error_setting_up_the_request": "Erreur lors de la configuration de la demande", + "failed_to_start_download_for_item_unexpected_error": "Échec du démarrage du téléchargement pour {{item}}: Erreur inattendue", + "all_files_folders_and_jobs_deleted_successfully": "Tous les fichiers, dossiers et travaux ont été supprimés avec succès", + "an_error_occured_while_deleting_files_and_jobs": "Une erreur s'est produite lors de la suppression des fichiers et des travaux", + "go_to_downloads": "Aller aux téléchargements" + } } }, "search": { "search_title": "Recherche", "search_here": "Rechercher ici...", "search": "Rechercher...", - "no_results_found_for": "Aucun résultat trouvé pour" + "no_results_found_for": "Aucun résultat trouvé pour", + "movies": "Films", + "series": "Séries", + "episodes": "Épisodes", + "collections": "Collections", + "actors": "Acteurs", + "artists": "Artistes", + "albums": "Albums", + "songs": "Chansons", + "requested_movies": "Films demandés", + "requested_series": "Séries demandées" }, "library": { "library_title": "Bibliothèque", @@ -77,7 +236,40 @@ } }, "favorites": { - "favorites_title": "Favoris" + "favorites_title": "Favoris", + "series": "Séries", + "movies": "Films", + "episodes": "Épisodes", + "videos": "Vidéos", + "boxsets": "Coffrets", + "playlists": "Listes de lecture", + "music_albums": "Albums de musique", + "audio": "Audio" + }, + "player": { + "error": "Erreur", + "failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux", + "an_error_occured_while_playing_the_video": "Une erreur s'est produite lors de la lecture de la vidéo", + "client_error": "Erreur client", + "could_not_create_stream_for_chromecast": "Impossible de créer un flux pour Chromecast", + "message_from_server": "Message du serveur: {{message}}", + "video_has_finished_playing": "La vidéo a fini de jouer!" + }, + "jellyseerr":{ + "confirm": "Confirmer", + "cancel": "Annuler", + "yes": "Oui", + "are_you_sure_you_want_to_request_all_seasons": "Êtes-vous sûr de vouloir demander toutes les saisons?", + "failed_to_login": "Échec de la connexion", + "toasts": { + "jellyseer_does_not_meet_requirements": "Jellyseer ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.", + "jellyseerr_test_failed": "Échec du test de Jellyseerr", + "failed_to_test_jellyseerr_server_url": "Échec du test de l'URL du serveur Jellyseerr", + "issue_submitted": "Problème soumis!", + "requested_item": "{{item}}} demandé!", + "you_dont_have_permission_to_request": "Vous n'avez pas la permission de demander {{item}}", + "something_went_wrong_requesting_media": "Quelque chose s'est mal passé en demandant le média!" + } }, "tabs": { "home": "Accueil", From 894305e1269022e036290f9e86e5f4ff4d73b8b8 Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Sat, 4 Jan 2025 14:49:56 -0500 Subject: [PATCH 19/34] Item Card Fields --- components/AudioTrackSelector.tsx | 5 ++++- components/BitrateSelector.tsx | 5 ++++- components/ItemTechnicalDetails.tsx | 12 +++++++----- components/MoreMoviesWithActor.tsx | 4 +++- components/OverviewText.tsx | 4 +++- components/SimilarItems.tsx | 6 ++++-- components/SubtitleTrackSelector.tsx | 5 ++++- components/series/CastAndCrew.tsx | 4 +++- components/series/CurrentSeries.tsx | 4 +++- components/series/JellyseerrSeasons.tsx | 4 ++-- components/series/NextUp.tsx | 8 +++++--- components/series/SeasonDropdown.tsx | 5 +++-- components/series/SeasonPicker.tsx | 5 +++-- components/settings/AudioToggles.tsx | 2 +- components/settings/SubtitleToggles.tsx | 2 +- translations/en.json | 25 +++++++++++++++++++++++-- translations/fr.json | 19 +++++++++++++++++++ 17 files changed, 92 insertions(+), 27 deletions(-) diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index 75fd659c..8302624e 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react"; import { TouchableOpacity, View } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "./common/Text"; +import { useTranslation } from "react-i18next"; interface Props extends React.ComponentProps { source?: MediaSourceInfo; @@ -26,6 +27,8 @@ export const AudioTrackSelector: React.FC = ({ [audioStreams, selected] ); + const { t } = useTranslation(); + return ( = ({ - Audio + {t("series.audio")} {selectedAudioSteam?.DisplayTitle} diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index 0f1bd28b..ccb62f20 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -2,6 +2,7 @@ import { TouchableOpacity, View } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "./common/Text"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; export type Bitrate = { key: string; @@ -59,6 +60,8 @@ export const BitrateSelector: React.FC = ({ ); }, []); + const { t } = useTranslation(); + return ( = ({ - Quality + {t("series.quality")} {BITRATES.find((b) => b.value === selected?.value)?.key} diff --git a/components/ItemTechnicalDetails.tsx b/components/ItemTechnicalDetails.tsx index 0c472192..bb10784b 100644 --- a/components/ItemTechnicalDetails.tsx +++ b/components/ItemTechnicalDetails.tsx @@ -15,6 +15,7 @@ import { BottomSheetScrollView, } from "@gorhom/bottom-sheet"; import { Button } from "./Button"; +import { useTranslation } from "react-i18next"; interface Props { source?: MediaSourceInfo; @@ -22,15 +23,16 @@ interface Props { export const ItemTechnicalDetails: React.FC = ({ source, ...props }) => { const bottomSheetModalRef = useRef(null); + const { t } = useTranslation(); return ( - Video + {t("series.video")} bottomSheetModalRef.current?.present()}> - More details + {t("series.more_details")} = ({ source, ...props }) => { - Video + {t("series.video")} - Audio + {t("series.audio")} = ({ source, ...props }) => { - Subtitles + {t("series.subtitles")} = ({ }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); + const { t } = useTranslation(); const { data: actor } = useQuery({ queryKey: ["actor", actorId], @@ -76,7 +78,7 @@ export const MoreMoviesWithActor: React.FC = ({ return ( - More with {actor?.Name} + {t("series.more_with", {name: actor?.Name})} = ({ ...props }) => { const [limit, setLimit] = useState(characterLimit); + const { t } = useTranslation(); if (!text) return null; return ( - Overview + {t("series.overview")} setLimit((prev) => diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx index 46815b6d..f275ddfc 100644 --- a/components/SimilarItems.tsx +++ b/components/SimilarItems.tsx @@ -12,6 +12,7 @@ import { ItemCardText } from "./ItemCardText"; import { Loader } from "./Loader"; import { HorizontalScroll } from "./common/HorrizontalScroll"; import { TouchableItemRouter } from "./common/TouchableItemRouter"; +import { useTranslation } from "react-i18next"; interface SimilarItemsProps extends ViewProps { itemId?: string | null; @@ -23,6 +24,7 @@ export const SimilarItems: React.FC = ({ }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); + const { t } = useTranslation(); const { data: similarItems, isLoading } = useQuery({ queryKey: ["similarItems", itemId], @@ -47,12 +49,12 @@ export const SimilarItems: React.FC = ({ return ( - Similar items + {t("series.similar_items")} ( { source?: MediaSourceInfo; @@ -37,6 +38,8 @@ export const SubtitleTrackSelector: React.FC = ({ if (subtitleStreams.length === 0) return null; + const { t } = useTranslation(); + return ( = ({ - Subtitle + {t("series.subtitles")} {selectedSubtitleSteam diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx index 2b312f0e..6b8da475 100644 --- a/components/series/CastAndCrew.tsx +++ b/components/series/CastAndCrew.tsx @@ -12,6 +12,7 @@ import { HorizontalScroll } from "../common/HorrizontalScroll"; import { Text } from "../common/Text"; import Poster from "../posters/Poster"; import { itemRouter } from "../common/TouchableItemRouter"; +import { useTranslation } from "react-i18next"; interface Props extends ViewProps { item?: BaseItemDto | null; @@ -21,6 +22,7 @@ interface Props extends ViewProps { export const CastAndCrew: React.FC = ({ item, loading, ...props }) => { const [api] = useAtom(apiAtom); const segments = useSegments(); + const { t } = useTranslation(); const from = segments[2]; const destinctPeople = useMemo(() => { @@ -40,7 +42,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => { return ( - Cast & Crew + {t("series.cast_and_crew")} i.Id.toString()} diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx index e573929a..cf2eba11 100644 --- a/components/series/CurrentSeries.tsx +++ b/components/series/CurrentSeries.tsx @@ -8,6 +8,7 @@ import Poster from "../posters/Poster"; import { HorizontalScroll } from "../common/HorrizontalScroll"; import { Text } from "../common/Text"; import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; +import { useTranslation } from "react-i18next"; interface Props extends ViewProps { item?: BaseItemDto | null; @@ -15,10 +16,11 @@ interface Props extends ViewProps { export const CurrentSeries: React.FC = ({ item, ...props }) => { const [api] = useAtom(apiAtom); + const { t } = useTranslation(); return ( - Series + {t("series.series")} - Seasons + {t("series.seasons")} {!allSeasonsAvailable && ( @@ -210,7 +210,7 @@ const JellyseerrSeasons: React.FC<{ )} ListHeaderComponent={() => ( - Seasons + {t("series.seasons")} {!allSeasonsAvailable && ( diff --git a/components/series/NextUp.tsx b/components/series/NextUp.tsx index 95834b9d..40a1adf5 100644 --- a/components/series/NextUp.tsx +++ b/components/series/NextUp.tsx @@ -12,10 +12,12 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { ItemCardText } from "../ItemCardText"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { FlashList } from "@shopify/flash-list"; +import { useTranslation } from "react-i18next"; export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => { const [user] = useAtom(userAtom); const [api] = useAtom(apiAtom); + const { t } = useTranslation(); const { data: items } = useQuery({ queryKey: ["nextUp", seriesId], @@ -37,14 +39,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => { if (!items?.length) return ( - Next up - No items to display + {t("series.next_up")} + {t("series.no_items_to_display")} ); return ( - Next up + {t("series.next_up")} = ({ - Season {seasonIndex} + {t("series.season")} {seasonIndex} @@ -104,7 +105,7 @@ export const SeasonDropdown: React.FC = ({ collisionPadding={8} sideOffset={8} > - Seasons + {t("series.seasons")} {seasons?.sort(sortByIndex).map((season: any) => ( = ({ item, initialSeasonIndex }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); + const { t } = useTranslation(); const seasonIndex = useMemo( () => seasonIndexState[item.Id ?? ""], @@ -210,7 +211,7 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { {(episodes?.length || 0) === 0 ? ( - No episodes for this season + {t("series.no_episodes_for_this_seasonz")} ) : null} diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx index 366b57fe..6bcdbb36 100644 --- a/components/settings/AudioToggles.tsx +++ b/components/settings/AudioToggles.tsx @@ -68,7 +68,7 @@ export const AudioToggles: React.FC = ({ ...props }) => { }); }} > - None + t("home.settings.audio.none") {cultures?.map((l) => ( = ({ ...props }) => { }); }} > - None + {t("home.settings.subtitles.none")} {cultures?.map((l) => ( Date: Sat, 4 Jan 2025 15:26:24 -0500 Subject: [PATCH 20/34] Fix Language Selector Setting Component --- app/(auth)/(tabs)/(home)/settings.tsx | 2 ++ components/settings/AppLanguageSelector.tsx | 38 +++++++++------------ 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 7889ec53..d43d73ea 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -10,6 +10,7 @@ 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"; @@ -63,6 +64,7 @@ export default function settings() { + diff --git a/components/settings/AppLanguageSelector.tsx b/components/settings/AppLanguageSelector.tsx index a160735d..5fdddba8 100644 --- a/components/settings/AppLanguageSelector.tsx +++ b/components/settings/AppLanguageSelector.tsx @@ -1,31 +1,26 @@ import * as DropdownMenu from "zeego/dropdown-menu"; -import { TouchableOpacity, View } from "react-native"; +import { TouchableOpacity, View, ViewProps } from "react-native"; import { Text } from "../common/Text"; import { useSettings } from "@/utils/atoms/settings"; -import { t } from "i18next"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; +import { useTranslation } from "react-i18next"; import { APP_LANGUAGES } from "@/i18n"; -export const AppLanguageSelector = () => { +interface Props extends ViewProps {} + +export const AppLanguageSelector: React.FC = ({ ...props }) => { const [settings, updateSettings] = useSettings(); + const { t } = useTranslation(); + + if (!settings) return null; return ( - - - {t("home.settings.languages.title")} - - + - - - {t("home.settings.languages.app_language")} - - - {t("home.settings.languages.app_language_description")} - - + @@ -74,7 +69,8 @@ export const AppLanguageSelector = () => { ))} - - + + + ); }; From 459ca3245bd2c3ccbcdc753f5fb44cfdf1ea5ae2 Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Sat, 4 Jan 2025 15:39:04 -0500 Subject: [PATCH 21/34] Rename card field --- components/AudioTrackSelector.tsx | 2 +- components/BitrateSelector.tsx | 2 +- components/DownloadItem.tsx | 8 ++++---- components/ItemTechnicalDetails.tsx | 10 +++++----- components/MoreMoviesWithActor.tsx | 2 +- components/OverviewText.tsx | 4 ++-- components/SimilarItems.tsx | 4 ++-- components/SubtitleTrackSelector.tsx | 2 +- components/series/CastAndCrew.tsx | 2 +- components/series/CurrentSeries.tsx | 2 +- components/series/JellyseerrSeasons.tsx | 4 ++-- components/series/NextUp.tsx | 6 +++--- components/series/SeasonDropdown.tsx | 4 ++-- components/series/SeasonPicker.tsx | 4 ++-- translations/en.json | 13 +++++++++++-- translations/fr.json | 15 ++++++++++++--- 16 files changed, 51 insertions(+), 33 deletions(-) diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index 8302624e..b4ab7b9a 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -39,7 +39,7 @@ export const AudioTrackSelector: React.FC = ({ - {t("series.audio")} + {t("item_card.audio")} {selectedAudioSteam?.DisplayTitle} diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index ccb62f20..1f1c8bc0 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -73,7 +73,7 @@ export const BitrateSelector: React.FC = ({ - {t("series.quality")} + {t("item_card.quality")} {BITRATES.find((b) => b.value === selected?.value)?.key} diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 737ff6cc..4df4601a 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -332,7 +332,7 @@ export const DownloadItems: React.FC = ({ {title} - {subtitle || `Download ${itemsNotDownloaded.length} items`} + {subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})} @@ -370,13 +370,13 @@ export const DownloadItems: React.FC = ({ onPress={acceptDownloadOptions} color="purple" > - Download + {t("item_card.download.download_button")} {usingOptimizedServer - ? "Using optimized server" - : "Using default method"} + ? t("item_card.download.using_optimized_server") + : t("item_card.download.using_default_method")} diff --git a/components/ItemTechnicalDetails.tsx b/components/ItemTechnicalDetails.tsx index bb10784b..6b5852a4 100644 --- a/components/ItemTechnicalDetails.tsx +++ b/components/ItemTechnicalDetails.tsx @@ -27,12 +27,12 @@ export const ItemTechnicalDetails: React.FC = ({ source, ...props }) => { return ( - {t("series.video")} + {t("item_card.video")} bottomSheetModalRef.current?.present()}> - {t("series.more_details")} + {t("item_card.more_details")} = ({ source, ...props }) => { - {t("series.video")} + {t("item_card.video")} - {t("series.audio")} + {t("item_card.audio")} = ({ source, ...props }) => { - {t("series.subtitles")} + {t("item_card.subtitles")} = ({ return ( - {t("series.more_with", {name: actor?.Name})} + {t("item_card.more_with", {name: actor?.Name})} = ({ return ( - {t("series.overview")} + {t("item_card.overview")} setLimit((prev) => @@ -33,7 +33,7 @@ export const OverviewText: React.FC = ({ {tc(text, limit)} {text.length > characterLimit && ( - {limit === characterLimit ? "Show more" : "Show less"} + {limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")} )} diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx index f275ddfc..45914d9f 100644 --- a/components/SimilarItems.tsx +++ b/components/SimilarItems.tsx @@ -49,12 +49,12 @@ export const SimilarItems: React.FC = ({ return ( - {t("series.similar_items")} + {t("item_card.similar_items")} ( = ({ - {t("series.subtitles")} + {t("item_card.subtitles")} {selectedSubtitleSteam diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx index 6b8da475..2d527af6 100644 --- a/components/series/CastAndCrew.tsx +++ b/components/series/CastAndCrew.tsx @@ -42,7 +42,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => { return ( - {t("series.cast_and_crew")} + {t("item_card.cast_and_crew")} i.Id.toString()} diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx index cf2eba11..f95bb10a 100644 --- a/components/series/CurrentSeries.tsx +++ b/components/series/CurrentSeries.tsx @@ -20,7 +20,7 @@ export const CurrentSeries: React.FC = ({ item, ...props }) => { return ( - {t("series.series")} + {t("item_card.series")} - {t("series.seasons")} + {t("item_card.seasons")} {!allSeasonsAvailable && ( @@ -210,7 +210,7 @@ const JellyseerrSeasons: React.FC<{ )} ListHeaderComponent={() => ( - {t("series.seasons")} + {t("item_card.seasons")} {!allSeasonsAvailable && ( diff --git a/components/series/NextUp.tsx b/components/series/NextUp.tsx index 40a1adf5..c76a61c6 100644 --- a/components/series/NextUp.tsx +++ b/components/series/NextUp.tsx @@ -39,14 +39,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => { if (!items?.length) return ( - {t("series.next_up")} - {t("series.no_items_to_display")} + {t("item_card.next_up")} + {t("item_card.no_items_to_display")} ); return ( - {t("series.next_up")} + {t("item_card.next_up")} = ({ - {t("series.season")} {seasonIndex} + {t("item_card.season")} {seasonIndex} @@ -105,7 +105,7 @@ export const SeasonDropdown: React.FC = ({ collisionPadding={8} sideOffset={8} > - {t("series.seasons")} + {t("item_card.seasons")} {seasons?.sort(sortByIndex).map((season: any) => ( = ({ item, initialSeasonIndex }) => { /> {episodes?.length || 0 > 0 ? ( ( @@ -211,7 +211,7 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { {(episodes?.length || 0) === 0 ? ( - {t("series.no_episodes_for_this_seasonz")} + {t("item_card.no_episodes_for_this_seasonz")} ) : null} diff --git a/translations/en.json b/translations/en.json index de3990a8..f10f05ec 100644 --- a/translations/en.json +++ b/translations/en.json @@ -257,7 +257,7 @@ "message_from_server": "Message from server: {{message}}", "video_has_finished_playing": "Video has finished playing!" }, - "series": { + "item_card": { "next_up": "Next up", "no_items_to_display": "No items to display", "cast_and_crew": "Cast & Crew", @@ -274,7 +274,16 @@ "more_details": "More details", "quality": "Quality", "audio": "Audio", - "subtitles": "Subtitle" + "subtitles": "Subtitle", + "show_more": "Show more", + "show_less": "Show less", + "download": { + "download_season": "Download Season", + "download_x_item": "Download {{item_count}} items", + "download_button": "Download", + "using_optimized_server": "Using optimized server", + "using_default_method": "Using default method", + } }, "jellyseerr":{ "confirm": "Confirm", diff --git a/translations/fr.json b/translations/fr.json index cc5379b9..e2c32397 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -255,7 +255,7 @@ "message_from_server": "Message du serveur: {{message}}", "video_has_finished_playing": "La vidéo a fini de jouer!" }, - "series": { + "item_card": { "next_up": "À suivre", "no_items_to_display": "Aucun item à afficher", "cast_and_crew": "Distribution et équipe", @@ -272,7 +272,16 @@ "more_details": "Plus de détails", "quality": "Qualité", "audio": "Audio", - "subtitles": "Sous-titres" + "subtitles": "Sous-titres", + "show_more": "Afficher plus", + "show_less": "Afficher moins", + "download": { + "download_season": "Télécharger la saison", + "download_x_item": "Télécharger {{item_count}} items", + "download_button": "Télécharger", + "using_optimized_server": "Avec le serveur de versions optimisées", + "using_default_method": "Avec la méthode par défaut" + } }, "jellyseerr":{ "confirm": "Confirmer", @@ -294,7 +303,7 @@ "home": "Accueil", "search": "Recherche", "library": "Bibliothèque", - "custom_links": "Liens personalisés", + "custom_links": "Liens personnalisés", "favorites": "Favoris" } } From 53ea1cc899f7d0b9e65c0fc8fb1cfd642fb9e7ee Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Sat, 4 Jan 2025 16:41:54 -0500 Subject: [PATCH 22/34] More Translations --- app/(auth)/(tabs)/(custom-links)/index.tsx | 4 +- .../(home)/settings/jellyseerr/page.tsx | 11 +-- .../(home)/settings/optimized-server/page.tsx | 15 +++-- .../actors/[actorId].tsx | 4 +- .../albums/[albumId].tsx | 3 +- .../artists/[artistId].tsx | 3 +- .../artists/index.tsx | 3 +- .../collections/[collectionId].tsx | 2 +- .../items/page.tsx | 4 +- .../jellyseerr/page.tsx | 18 ++--- app/(auth)/(tabs)/(search)/index.tsx | 6 +- app/(auth)/player/direct-player.tsx | 2 +- app/(auth)/player/music-player.tsx | 6 +- app/(auth)/player/transcoding-player.tsx | 6 +- components/MediaSourceSelector.tsx | 5 +- .../common/InfiniteHorrizontalScroll.tsx | 3 +- components/downloads/ActiveDownloads.tsx | 2 +- components/filters/FilterSheet.tsx | 2 +- components/home/ScrollingCollectionList.tsx | 5 +- components/series/SeasonPicker.tsx | 2 +- components/settings/DownloadSettings.tsx | 4 +- components/settings/OptimizedServerForm.tsx | 12 ++-- components/settings/QuickConnect.tsx | 2 +- .../controls/NextEpisodeCountDownButton.tsx | 5 +- components/vlc/VideoDebugInfo.tsx | 15 +++-- translations/en.json | 51 ++++++++++++-- translations/fr.json | 67 +++++++++++++++---- 27 files changed, 189 insertions(+), 73 deletions(-) diff --git a/app/(auth)/(tabs)/(custom-links)/index.tsx b/app/(auth)/(tabs)/(custom-links)/index.tsx index bf6dc46b..b7607569 100644 --- a/app/(auth)/(tabs)/(custom-links)/index.tsx +++ b/app/(auth)/(tabs)/(custom-links)/index.tsx @@ -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([]); + const { t } = useTranslation(); const getMenuLinks = useCallback(async () => { try { @@ -67,7 +69,7 @@ export default function menuLinks() { )} ListEmptyComponent={ - No links + {t("custom_links.no_links")} } /> diff --git a/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx index af4247d5..eaa5e8ae 100644 --- a/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx @@ -11,10 +11,13 @@ 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"; export default function page() { const navigation = useNavigation(); + const { t } = useTranslation(); + const [api] = useAtom(apiAtom); const [settings, updateSettings] = useSettings(); @@ -24,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; } @@ -42,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")); }, }); diff --git a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx index b47d565f..425efc66 100644 --- a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx @@ -10,10 +10,13 @@ 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"; export default function page() { const navigation = useNavigation(); + const { t } = useTranslation(); + const [api] = useAtom(apiAtom); const [settings, updateSettings] = useSettings(); @@ -23,7 +26,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 +44,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")); }, }); @@ -57,13 +60,13 @@ export default function page() { useEffect(() => { navigation.setOptions({ - title: "Optimized Server", + title: t("home.settings.downloads.optimized_server"), headerRight: () => saveMutation.isPending ? ( ) : ( onSave(optimizedVersionsServerUrl)}> - Save + {t("home.settings.downloads.save_button")} ), }); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/actors/[actorId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/actors/[actorId].tsx index 45dc8a4d..d2c15c3d 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/actors/[actorId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/actors/[actorId].tsx @@ -18,10 +18,12 @@ import { useLocalSearchParams } from "expo-router"; import { useAtom } from "jotai"; import { useCallback, useMemo } from "react"; import { View } from "react-native"; +import { useTranslation } from "react-i18next"; const page: React.FC = () => { const local = useLocalSearchParams(); const { actorId } = local as { actorId: string }; + const { t } = useTranslation(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -110,7 +112,7 @@ const page: React.FC = () => { - Appeared In + {t("item_card.appeared_in")} {album?.Name} - {songs?.TotalRecordCount} songs + {t("item_card.x_songs", { count: songs?.TotalRecordCount })} diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx index 8d82d205..aea5af16 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx @@ -5,6 +5,7 @@ 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 { t } from "i18next"; import { useAtom } from "jotai"; import { useEffect, useState } from "react"; import { FlatList, ScrollView, TouchableOpacity, View } from "react-native"; @@ -107,7 +108,7 @@ export default function page() { {artist?.Name} - {albums.TotalRecordCount} albums + {t("item_card.x_albums", { count: albums.TotalRecordCount })} diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx index 4827287e..55471ed5 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx @@ -7,6 +7,7 @@ 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 { t } from "i18next"; import { useAtom } from "jotai"; import { useMemo, useState } from "react"; import { FlatList, TouchableOpacity, View } from "react-native"; @@ -81,7 +82,7 @@ export default function page() { }} ListHeaderComponent={ - Artists + {t("item_card.artists")} } nestedScrollEnabled diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx index cee529bc..c751c1a1 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx @@ -377,7 +377,7 @@ const page: React.FC = () => { - No results + {t("search.no_results")} } extraData={[ diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx index 38b0115d..a61114bd 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx @@ -13,11 +13,13 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; +import { useTranslation } from "react-i18next"; const Page: React.FC = () => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { id } = useLocalSearchParams() as { id: string }; + const { t } = useTranslation(); const { data: item, isError } = useQuery({ queryKey: ["item", id], @@ -74,7 +76,7 @@ const Page: React.FC = () => { if (isError) return ( - Could not load item + {t("item_card.could_not_load_item")} ); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx index 42edcb59..92a48c9c 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx @@ -27,10 +27,12 @@ import * as DropdownMenu from "zeego/dropdown-menu"; import { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; import { JellyserrRatings } from "@/components/Ratings"; +import { useTranslation } from "react-i18next"; const Page: React.FC = () => { const insets = useSafeAreaInsets(); const params = useLocalSearchParams(); + const { t } = useTranslation(); const { mediaTitle, releaseYear, @@ -184,7 +186,7 @@ const Page: React.FC = () => { {canRequest ? ( ) : ( )} @@ -231,7 +233,7 @@ const Page: React.FC = () => { - Whats wrong? + {t("jellyseerr.whats_wrong")} @@ -240,13 +242,13 @@ const Page: React.FC = () => { - Issue Type + {t("jellyseerr.issue_type")} {issueType ? IssueTypeName[issueType] - : "Select an issue"} + : t("jellyseerr.select_an_issue")} @@ -260,7 +262,7 @@ const Page: React.FC = () => { collisionPadding={0} sideOffset={0} > - Types + {t("jellyseerr.types")} {Object.entries(IssueTypeName) .reverse() .map(([key, value], idx) => ( @@ -287,7 +289,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 @@ -297,7 +299,7 @@ const Page: React.FC = () => { diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 2d7afff8..614f9fa4 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -320,7 +320,7 @@ export default function search() { setSearchType("Library")}> setSearchType("Discover")}> - Results for {q} + {t("search.results_for_x")} {q} )} diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index a689ccb6..606c2d1e 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -423,7 +423,7 @@ export default function page() { if (isErrorItem || isErrorStreamUrl) return ( - Error + {t("player.error")} ); diff --git a/app/(auth)/player/music-player.tsx b/app/(auth)/player/music-player.tsx index eca16b4c..d48b0840 100644 --- a/app/(auth)/player/music-player.tsx +++ b/app/(auth)/player/music-player.tsx @@ -25,6 +25,7 @@ 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"; +import { useTranslation } from "react-i18next"; export default function page() { const api = useAtomValue(apiAtom); @@ -32,6 +33,7 @@ export default function page() { const [settings] = useSettings(); const videoRef = useRef(null); const windowDimensions = useWindowDimensions(); + const { t } = useTranslation(); const firstTime = useRef(true); @@ -278,14 +280,14 @@ export default function page() { if (isErrorItem || isErrorStreamUrl) return ( - Error + {t("player.error")} ); if (!item || !stream) return ( - Error + {t("player.error")} ); diff --git a/app/(auth)/player/transcoding-player.tsx b/app/(auth)/player/transcoding-player.tsx index bcb9a6e4..b174cc41 100644 --- a/app/(auth)/player/transcoding-player.tsx +++ b/app/(auth)/player/transcoding-player.tsx @@ -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(null); + const { t } = useTranslation(); const firstTime = useRef(true); const revalidateProgressCache = useInvalidatePlaybackProgressCache(); @@ -373,7 +375,7 @@ const Player = () => { if (isErrorItem || isErrorStreamUrl) return ( - Error + {t("player.error")} ); @@ -440,7 +442,7 @@ const Player = () => { /> ) : ( - No video source... + {t("player.no_video_source")} )} diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index 34f02fd9..ec757f23 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -8,6 +8,7 @@ import { TouchableOpacity, View } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "./common/Text"; import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb"; +import { useTranslation } from "react-i18next"; interface Props extends React.ComponentProps { item: BaseItemDto; @@ -29,6 +30,8 @@ export const MediaSourceSelector: React.FC = ({ [item, selected] ); + const { t } = useTranslation(); + return ( = ({ - Video + {t("item_card.video")} {selectedName} diff --git a/components/common/InfiniteHorrizontalScroll.tsx b/components/common/InfiniteHorrizontalScroll.tsx index e8281d0e..f3c504f1 100644 --- a/components/common/InfiniteHorrizontalScroll.tsx +++ b/components/common/InfiniteHorrizontalScroll.tsx @@ -15,6 +15,7 @@ import Animated, { } from "react-native-reanimated"; import { Loader } from "../Loader"; import { Text } from "./Text"; +import { t } from "i18next"; interface HorizontalScrollProps extends Omit, "renderItem" | "data" | "style"> { @@ -136,7 +137,7 @@ export function InfiniteHorizontalScroll({ showsHorizontalScrollIndicator={false} ListEmptyComponent={ - No data available + {t("item_card.no_data_available")} } {...props} diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 54e8ef59..3008c5ff 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -154,7 +154,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { {process.speed?.toFixed(2)}x )} {eta(process) && ( - ETA {eta(process)} + {t("home.downloads.eta", {eta: eta(process)})} )} diff --git a/components/filters/FilterSheet.tsx b/components/filters/FilterSheet.tsx index 25486ce9..cc5d4300 100644 --- a/components/filters/FilterSheet.tsx +++ b/components/filters/FilterSheet.tsx @@ -155,7 +155,7 @@ export const FilterSheet = ({ > {title} - {_data?.length} items + {t("search.items", {count: _data?.length})} {showSearch && ( = ({ if (hideIfEmpty === true && data?.length === 0) return null; + const { t } = useTranslation(); + return ( @@ -50,7 +53,7 @@ export const ScrollingCollectionList: React.FC = ({ {isLoading === false && data?.length === 0 && ( - No items + {t("home.no_items")} )} {isLoading ? ( diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index 927b741a..be625432 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -211,7 +211,7 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { {(episodes?.length || 0) === 0 ? ( - {t("item_card.no_episodes_for_this_seasonz")} + {t("item_card.no_episodes_for_this_season")} ) : null} diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx index 98c01199..131ec9a6 100644 --- a/components/settings/DownloadSettings.tsx +++ b/components/settings/DownloadSettings.tsx @@ -30,8 +30,8 @@ export const DownloadSettings: React.FC = ({ ...props }) => { {settings.downloadMethod === "remux" - ? "Default" - : "Optimized"} + ? t("home.settings.downloads.default") + : t("home.settings.downloads.optimized")} = ({ Linking.openURL("https://github.com/streamyfin/optimized-versions-server"); }; + const { t } = useTranslation(); + return ( - URL + {t("home.settings.downloads.url")} = ({ - Enter the URL for the optimize server. The URL should include http or - https and optionally the port.{" "} + {t("home.settings.downloads.optimized_version_hint")}{" "} - Read more about the optimize server. + {t("home.settings.downloads.read_more_about_optimized_server")} diff --git a/components/settings/QuickConnect.tsx b/components/settings/QuickConnect.tsx index 5cab8b15..04e0ee5d 100644 --- a/components/settings/QuickConnect.tsx +++ b/components/settings/QuickConnect.tsx @@ -86,7 +86,7 @@ export const QuickConnect: React.FC = ({ ...props }) => { - Quick Connect + {t("home.settings.quick_connect.quick_connect_title")} diff --git a/components/video-player/controls/NextEpisodeCountDownButton.tsx b/components/video-player/controls/NextEpisodeCountDownButton.tsx index 6f5239e6..e77c6198 100644 --- a/components/video-player/controls/NextEpisodeCountDownButton.tsx +++ b/components/video-player/controls/NextEpisodeCountDownButton.tsx @@ -9,6 +9,7 @@ import Animated, { runOnJS, } from "react-native-reanimated"; import { Colors } from "@/constants/Colors"; +import { useTranslation } from "react-i18next"; interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps { onFinish?: () => void; @@ -63,6 +64,8 @@ const NextEpisodeCountDownButton: React.FC = ({ return null; } + const { t } = useTranslation(); + return ( = ({ > - Next Episode + {t("player.next_episode")} ); diff --git a/components/vlc/VideoDebugInfo.tsx b/components/vlc/VideoDebugInfo.tsx index 5ae04517..852dcc2f 100644 --- a/components/vlc/VideoDebugInfo.tsx +++ b/components/vlc/VideoDebugInfo.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useState } from "react"; import { TouchableOpacity, View, ViewProps } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "../common/Text"; +import { useTranslation } from "react-i18next"; interface Props extends ViewProps { playerRef: React.RefObject; @@ -32,6 +33,8 @@ export const VideoDebugInfo: React.FC = ({ playerRef, ...props }) => { const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + return ( = ({ playerRef, ...props }) => { }} {...props} > - Playback State: - Audio Tracks: + {t("item_card.playback_state")} + {t("item_card.audio_tracks")} {audioTracks && audioTracks.map((track, index) => ( - {track.name} (Index: {track.index}) + {track.name} ({t("item_card.index")} {track.index}) ))} - Subtitle Tracks: + {t("item_card.subtitles_tracks")} {subtitleTracks && subtitleTracks.map((track, index) => ( - {track.name} (Index: {track.index}) + {track.name} ({t("item_card.index")} {track.index}) ))} = ({ playerRef, ...props }) => { } }} > - Refresh Tracks + {t("item_card.refresh_tracks")} ); diff --git a/translations/en.json b/translations/en.json index f10f05ec..83f4716b 100644 --- a/translations/en.json +++ b/translations/en.json @@ -26,6 +26,7 @@ "home": { "home": "Home", "no_internet": "No Internet", + "no_items": "No items", "no_internet_message": "No worries, you can still watch\ndownloaded content.", "go_to_downloads": "Go to downloads", "oops": "Oops!", @@ -46,7 +47,7 @@ "app_version": "App Version" }, "quick_connect": { - "quick_connect_title": "Quick connect", + "quick_connect_title": "Quick Connect", "authorize_button": "Authorize Quick Connect", "enter_the_quick_connect_code": "Enter the Quick Connect code", "success": "Success", @@ -87,7 +88,15 @@ "download_method": "Download method", "remux_max_download": "Remux max download", "auto_download": "Auto download", - "optimized_versions_server": "Optimized versions server" + "optimized_versions_server": "Optimized versions server", + "save_button": "Save", + "optimized_server": "Optimized Server", + "optimized": "Optimized", + "default": "Default", + "optimized_version_hint": "Enter the URL for the optimize server. The URL should include http or https and optionally the port.", + "read_more_about_optimized_server": "Read more about the optimize server.", + "url":"URL", + "server_url_placeholder": "http(s)://domain.org:port" }, "plugins": { "plugins_title": "Plugins", @@ -149,7 +158,8 @@ "background_downloads_enabled": "Background downloads enabled", "background_downloads_disabled": "Background downloads disabled", "connected": "Connected", - "could_not_connect": "Could not connect" + "could_not_connect": "Could not connect", + "invalid_url": "Invalid URL" } }, "downloads": { @@ -172,6 +182,7 @@ "delete": "Delete", "something_went_wrong": "Something went wrong", "could_not_get_stream_url_from_jellyfin": "Could not get stream URL from Jellyfin", + "eta": "ETA {{eta}}", "toasts": { "you_are_not_allowed_to_download_files": "You are not allowed to download files.", "deleted_all_movies_successfully": "Deleted all movies successfully!", @@ -199,9 +210,14 @@ } }, "search": { + "results_for_x": "Results for ", "search_title": "Search", "search_here": "Search here...", "search": "Search...", + "x_items": "{{count}} items", + "library": "Library", + "discover": "Discover", + "no_results": "No results", "no_results_found_for": "No results found for", "movies": "Movies", "series": "Series", @@ -248,6 +264,9 @@ "music_albums": "Music Albums", "audio": "Audio" }, + "custom_links": { + "no_links": "No links" + }, "player": { "error": "Error", "failed_to_get_stream_url": "Failed to get stream URL", @@ -255,7 +274,9 @@ "client_error": "Client error", "could_not_create_stream_for_chromecast": "Could not create stream for Chromecast", "message_from_server": "Message from server: {{message}}", - "video_has_finished_playing": "Video has finished playing!" + "video_has_finished_playing": "Video has finished playing!", + "no_video_source": "No video source...", + "next_episode": "Next Episode" }, "item_card": { "next_up": "Next up", @@ -264,7 +285,6 @@ "series": "Series", "seasons": "Seasons", "season": "Season", - "episodes": "Episodes", "no_episodes_for_this_season": "No episodes for this season", "overview": "Overview", "more_with": "More with {{name}}", @@ -277,18 +297,37 @@ "subtitles": "Subtitle", "show_more": "Show more", "show_less": "Show less", + "appeared_in": "Appeared in", + "x_songs": "{{count}} songs", + "x_albums": "{{count}} albums", + "artists": "Artists", + "could_not_load_item": "Could not load item", + "refresh_tracks": "Refresh Tracks", + "subtitle_tracks": "Subtitle Tracks:", + "audio_tracks": "Audio Tracks:", + "playback_state": "Playback State:", + "no_data_available": "No data available", + "index": "Index:", "download": { "download_season": "Download Season", "download_x_item": "Download {{item_count}} items", "download_button": "Download", "using_optimized_server": "Using optimized server", - "using_default_method": "Using default method", + "using_default_method": "Using default method" } }, "jellyseerr":{ "confirm": "Confirm", "cancel": "Cancel", "yes": "Yes", + "whats_wrong": "What's wrong?", + "issue_type": "Issue type", + "select_an_issue": "Select an issue", + "types": "Types", + "describe_the_issue": "(optional) Describe the issue...", + "submit_button": "Submit", + "report_issue_button": "Report issue", + "request_button": "Request", "are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?", "failed_to_login": "Failed to login", "toasts": { diff --git a/translations/fr.json b/translations/fr.json index e2c32397..755d4efa 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -6,11 +6,11 @@ "login_to_title": "Se connecter à", "username_placeholder": "Nom d'utilisateur", "password_placeholder": "Mot de passe", - "use_quick_connect": "Utiliser Quick Connect", + "use_quick_connect": "Utiliser Connexion Rapide", "login_button": "Se connecter", - "quick_connect": "Quick Connect", + "quick_connect": "Connexion Rapide", "enter_code_to_login": "Entrez le code {{code}} pour vous connecter", - "failed_to_initiate_quick_connect": "Échec de l'initialisation de Quick Connect", + "failed_to_initiate_quick_connect": "Échec de l'initialisation de Connexion Rapide", "got_it": "D'accord", "connection_failed": "La connection a échouée", "could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier l'URL et votre connection réseau." @@ -26,6 +26,7 @@ "home": { "home": "Accueil", "no_internet": "Pas d'Internet", + "no_items": "Aucun item", "no_internet_message": "Aucun problème, vous pouvez toujours regarder\nle contenu téléchargé.", "go_to_downloads": "Aller aux téléchargements", "oops": "Oups!", @@ -46,11 +47,11 @@ "app_version": "Version de l'application" }, "quick_connect": { - "quick_connect_title": "Quick connect", - "authorize_button": "Autoriser Quick Connect", - "enter_the_quick_connect_code": "Entrez le code Quick Connect", + "quick_connect_title": "Connexion Rapide", + "authorize_button": "Autoriser Connexion Rapide", + "enter_the_quick_connect_code": "Entrez le code Connexion Rapide", "success": "Succès", - "quick_connect_autorized": "Quick Connect autorisé", + "quick_connect_autorized": "Connexion Rapide autorisé", "error": "Errur", "invalid_code": "Code invalide" }, @@ -63,7 +64,8 @@ "audio_title": "Audio", "set_audio_track": "Configurer la piste audio à partir de l'élément précédent", "audio_language": "Langue audio", - "audio_hint": "Chosissez une langue audio par défaut." + "audio_hint": "Chosissez une langue audio par défaut.", + "none": "Aucun" }, "subtitles": { "subtitle_title": "Sous-titres", @@ -71,7 +73,8 @@ "subtitle_mode": "Mode des sous-titres", "set_subtitle_track": "Configurer la piste de sous-titres à partir de l'élément précédent", "subtitle_size": "Taille des sous-titres", - "subtitle_hint": "Configurez les préférences des sous-titres." + "subtitle_hint": "Configurez les préférences des sous-titres.", + "none": "Aucun" }, "other": { "other_title": "Autres", @@ -85,7 +88,15 @@ "download_method": "Méthode de téléchargement", "remux_max_download": "Téléchargement max remux", "auto_download": "Téléchargement automatique", - "optimized_versions_server": "Serveur de versions optimisées" + "optimized_versions_server": "Serveur de versions optimisées", + "save_button": "Enregistrer", + "optimized_server": "Serveur optimisé", + "optimized": "Optimisé", + "default": "Défaut", + "optimized_version_hint": "Entrez l'URL du serveur de versions optimisées. L'URL devrait inclure http ou https et optionnellement le port.", + "read_more_about_optimized_server": "Lisez-en plus sur le serveur de versions optimisées.", + "url": "URL", + "server_url_placeholder": "http(s)://domaine.org:port" }, "plugins": { "plugins_title": "Plugiciels", @@ -147,7 +158,8 @@ "background_downloads_enabled": "Téléchargements en arrière-plan activés", "background_downloads_disabled": "Téléchargements en arrière-plan désactivés", "connected": "Connecté", - "could_not_connect": "Impossible de se connecter" + "could_not_connect": "Impossible de se connecter", + "invalid_url": "URL invalide" } }, "downloads": { @@ -170,6 +182,7 @@ "delete": "Supprimer", "something_went_wrong": "Quelque chose s'est mal passé", "could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin", + "eta": "ETA {{eta}}", "toasts": { "you_are_not_allowed_to_download_files": "Vous n'êtes pas autorisé à télécharger des fichiers", "deleted_all_movies_successfully": "Tous les films ont été supprimés avec succès!", @@ -197,9 +210,14 @@ } }, "search": { + "results_for_x": "Résultats pour ", "search_title": "Recherche", "search_here": "Rechercher ici...", "search": "Rechercher...", + "x_items": "{{count}} items", + "library": "Bibliothèque", + "discover": "Découvrir", + "no_results": "Aucun résultat", "no_results_found_for": "Aucun résultat trouvé pour", "movies": "Films", "series": "Séries", @@ -245,6 +263,9 @@ "playlists": "Listes de lecture", "music_albums": "Albums de musique", "audio": "Audio" + }, + "custom_links": { + "no_links": "Aucun lien" }, "player": { "error": "Erreur", @@ -253,7 +274,9 @@ "client_error": "Erreur client", "could_not_create_stream_for_chromecast": "Impossible de créer un flux pour Chromecast", "message_from_server": "Message du serveur: {{message}}", - "video_has_finished_playing": "La vidéo a fini de jouer!" + "video_has_finished_playing": "La vidéo a fini de jouer!", + "no_video_source": "Aucune source vidéo...", + "next_episode": "Épisode suivant" }, "item_card": { "next_up": "À suivre", @@ -262,7 +285,6 @@ "series": "Séries", "seasons": "Saisons", "season": "Saison", - "episodes": "Épisodes", "no_episodes_for_this_season": "Aucun épisode pour cette saison", "overview": "Aperçu", "more_with": "Plus avec {{name}}", @@ -275,6 +297,17 @@ "subtitles": "Sous-titres", "show_more": "Afficher plus", "show_less": "Afficher moins", + "appeared_in": "Apparu dans", + "x_songs": "{{count}} chansons", + "x_albums": "{{count}} albums", + "artists": "Artistes", + "could_not_load_item": "Impossible de charger l'item", + "refresh_tracks": "Rafraîchir les pistes", + "subtitle_tracks": "Pistes de sous-titres:", + "audio_tracks": "Pistes audio:", + "playback_state": "État de lecture:", + "no_data_available": "Aucune donnée disponible", + "index": "Index:", "download": { "download_season": "Télécharger la saison", "download_x_item": "Télécharger {{item_count}} items", @@ -287,6 +320,14 @@ "confirm": "Confirmer", "cancel": "Annuler", "yes": "Oui", + "whats_wrong": "Qu'est-ce qui ne va pas?", + "issue_type": "Type de problème", + "select_an_issue": "Sélectionnez un problème", + "types": "Types", + "describe_the_issue": "(optionnel) Décrivez le problème...", + "submit_button": "Soumettre", + "report_issue_button": "Signaler un problème", + "request_button": "Demander", "are_you_sure_you_want_to_request_all_seasons": "Êtes-vous sûr de vouloir demander toutes les saisons?", "failed_to_login": "Échec de la connexion", "toasts": { From eb7fa93f9b7748f45c2414e81298360a6a14b26a Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Sun, 5 Jan 2025 15:26:48 -0500 Subject: [PATCH 23/34] remove dupe --- app/(auth)/(tabs)/(custom-links)/_layout.tsx | 4 +++- app/(auth)/(tabs)/(favorites)/_layout.tsx | 2 +- app/(auth)/(tabs)/(home)/_layout.tsx | 2 +- app/(auth)/(tabs)/(libraries)/_layout.tsx | 2 +- app/(auth)/(tabs)/(search)/_layout.tsx | 2 +- translations/en.json | 4 ---- translations/fr.json | 3 --- 7 files changed, 7 insertions(+), 12 deletions(-) diff --git a/app/(auth)/(tabs)/(custom-links)/_layout.tsx b/app/(auth)/(tabs)/(custom-links)/_layout.tsx index ed0529d4..c270b95d 100644 --- a/app/(auth)/(tabs)/(custom-links)/_layout.tsx +++ b/app/(auth)/(tabs)/(custom-links)/_layout.tsx @@ -1,7 +1,9 @@ import {Stack} from "expo-router"; import { Platform } from "react-native"; +import { useTranslation } from "react-i18next"; export default function CustomMenuLayout() { + const { t } = useTranslation(); return ( Date: Sun, 5 Jan 2025 16:03:19 -0500 Subject: [PATCH 24/34] livetv --- .../collections/[collectionId].tsx | 10 +++---- .../livetv/guide.tsx | 6 ++-- .../livetv/programs.tsx | 15 ++++++---- .../livetv/recordings.tsx | 4 ++- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 10 +++---- components/vlc/VideoDebugInfo.tsx | 12 ++++---- translations/en.json | 28 +++++++++++++----- translations/fr.json | 29 +++++++++++++------ 8 files changed, 72 insertions(+), 42 deletions(-) diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx index c751c1a1..87f316e6 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx @@ -247,7 +247,7 @@ const page: React.FC = () => { }} set={setSelectedGenres} values={selectedGenres} - title={t("library.headers.genres")} + title={t("library.filters.genres")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase()) @@ -274,7 +274,7 @@ const page: React.FC = () => { }} set={setSelectedYears} values={selectedYears} - title={t("library.headers.years")} + title={t("library.filters.years")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.includes(search)} /> @@ -299,7 +299,7 @@ const page: React.FC = () => { }} set={setSelectedTags} values={selectedTags} - title={t("library.headers.tags")} + title={t("library.filters.tags")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase()) @@ -317,7 +317,7 @@ const page: React.FC = () => { queryFn={async () => sortOptions.map((s) => s.key)} set={setSortBy} values={sortBy} - title={t("library.headers.sort_by")} + title={t("library.filters.sort_by")} renderItemLabel={(item) => sortOptions.find((i) => i.key === item)?.value || "" } @@ -337,7 +337,7 @@ const page: React.FC = () => { queryFn={async () => sortOrderOptions.map((s) => s.key)} set={setSortOrder} values={sortOrder} - title={t("library.headers.sort_order")} + title={t("library.filters.sort_order")} renderItemLabel={(item) => sortOrderOptions.find((i) => i.key === item)?.value || "" } diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx index 01652b5f..398d74b6 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx @@ -17,6 +17,7 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useTranslation } from "react-i18next"; const HOUR_HEIGHT = 30; const ITEMS_PER_PAGE = 20; @@ -177,6 +178,7 @@ const PageButtons: React.FC = ({ onNextPage, isNextDisabled, }) => { + const { t } = useTranslation(); return ( = ({ currentPage === 1 ? "text-gray-500" : "text-white" }`} > - Previous + {t("live_tv.previous")} Page {currentPage} @@ -206,7 +208,7 @@ const PageButtons: React.FC = ({ - Next + {t("live_tv.next")} { if (!api) return [] as BaseItemDto[]; const res = await getLiveTvApi(api).getRecommendedPrograms({ @@ -46,7 +49,7 @@ export default function page() { /> { if (!api) return [] as BaseItemDto[]; const res = await getLiveTvApi(api).getLiveTvPrograms({ @@ -68,7 +71,7 @@ export default function page() { /> { if (!api) return [] as BaseItemDto[]; const res = await getLiveTvApi(api).getLiveTvPrograms({ @@ -86,7 +89,7 @@ export default function page() { /> { if (!api) return [] as BaseItemDto[]; const res = await getLiveTvApi(api).getLiveTvPrograms({ @@ -104,7 +107,7 @@ export default function page() { /> { if (!api) return [] as BaseItemDto[]; const res = await getLiveTvApi(api).getLiveTvPrograms({ @@ -122,7 +125,7 @@ export default function page() { /> { if (!api) return [] as BaseItemDto[]; const res = await getLiveTvApi(api).getLiveTvPrograms({ diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx index 6e3f660e..4068f8a3 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx @@ -1,11 +1,13 @@ import { Text } from "@/components/common/Text"; import React from "react"; import { View } from "react-native"; +import { useTranslation } from "react-i18next"; export default function page() { + const { t } = useTranslation(); return ( - Coming soon + {t("live_tv.coming_soon")} ); } diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 45a61a6c..7eac65f9 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -303,7 +303,7 @@ const Page = () => { }} set={setSelectedGenres} values={selectedGenres} - title={t("library.headers.genres")} + title={t("library.filters.genres")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase()) @@ -330,7 +330,7 @@ const Page = () => { }} set={setSelectedYears} values={selectedYears} - title={t("library.headers.years")} + title={t("library.filters.years")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.includes(search)} /> @@ -355,7 +355,7 @@ const Page = () => { }} set={setSelectedTags} values={selectedTags} - title={t("library.headers.tags")} + title={t("library.filters.tags")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase()) @@ -373,7 +373,7 @@ const Page = () => { queryFn={async () => sortOptions.map((s) => s.key)} set={setSortBy} values={sortBy} - title={t("library.headers.sort_by")} + title={t("library.filters.sort_by")} renderItemLabel={(item) => sortOptions.find((i) => i.key === item)?.value || "" } @@ -393,7 +393,7 @@ const Page = () => { queryFn={async () => sortOrderOptions.map((s) => s.key)} set={setSortOrder} values={sortOrder} - title={t("library.headers.sort_order")} + title={t("library.filters.sort_order")} renderItemLabel={(item) => sortOrderOptions.find((i) => i.key === item)?.value || "" } diff --git a/components/vlc/VideoDebugInfo.tsx b/components/vlc/VideoDebugInfo.tsx index 852dcc2f..8a37659a 100644 --- a/components/vlc/VideoDebugInfo.tsx +++ b/components/vlc/VideoDebugInfo.tsx @@ -45,19 +45,19 @@ export const VideoDebugInfo: React.FC = ({ playerRef, ...props }) => { }} {...props} > - {t("item_card.playback_state")} - {t("item_card.audio_tracks")} + {t("player.playback_state")} + {t("player.audio_tracks")} {audioTracks && audioTracks.map((track, index) => ( - {track.name} ({t("item_card.index")} {track.index}) + {track.name} ({t("player.index")} {track.index}) ))} - {t("item_card.subtitles_tracks")} + {t("player.subtitles_tracks")} {subtitleTracks && subtitleTracks.map((track, index) => ( - {track.name} ({t("item_card.index")} {track.index}) + {track.name} ({t("player.index")} {track.index}) ))} = ({ playerRef, ...props }) => { } }} > - {t("item_card.refresh_tracks")} + {t("player.refresh_tracks")} ); diff --git a/translations/en.json b/translations/en.json index b88f67e1..1dae952c 100644 --- a/translations/en.json +++ b/translations/en.json @@ -242,7 +242,7 @@ "show_titles": "Show titles", "show_stats": "Show stats" }, - "headers": { + "filters": { "genres": "Genres", "years": "Years", "sort_by": "Sort By", @@ -272,7 +272,13 @@ "message_from_server": "Message from server: {{message}}", "video_has_finished_playing": "Video has finished playing!", "no_video_source": "No video source...", - "next_episode": "Next Episode" + "next_episode": "Next Episode", + "refresh_tracks": "Refresh Tracks", + "subtitle_tracks": "Subtitle Tracks:", + "audio_tracks": "Audio Tracks:", + "playback_state": "Playback State:", + "no_data_available": "No data available", + "index": "Index:" }, "item_card": { "next_up": "Next up", @@ -298,12 +304,6 @@ "x_albums": "{{count}} albums", "artists": "Artists", "could_not_load_item": "Could not load item", - "refresh_tracks": "Refresh Tracks", - "subtitle_tracks": "Subtitle Tracks:", - "audio_tracks": "Audio Tracks:", - "playback_state": "Playback State:", - "no_data_available": "No data available", - "index": "Index:", "download": { "download_season": "Download Season", "download_x_item": "Download {{item_count}} items", @@ -312,6 +312,18 @@ "using_default_method": "Using default method" } }, + "live_tv": { + "next": "Next", + "previous": "Previous", + "live_tv": "Live TV", + "coming_soon": "Coming soon", + "on_now": "On now", + "shows": "Shows", + "movies": "Movies", + "sports": "Sports", + "for_kids": "For Kids", + "news": "News" + }, "jellyseerr":{ "confirm": "Confirm", "cancel": "Cancel", diff --git a/translations/fr.json b/translations/fr.json index 16875f66..24f22f83 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -24,7 +24,6 @@ "clear_button": "Effacer" }, "home": { - "home": "Accueil", "no_internet": "Pas d'Internet", "no_items": "Aucun item", "no_internet_message": "Aucun problème, vous pouvez toujours regarder\nle contenu téléchargé.", @@ -243,7 +242,7 @@ "show_titles": "Afficher les titres", "show_stats": "Afficher les statistiques" }, - "headers": { + "filters": { "genres": "Genres", "years": "Années", "sort_by": "Trier par", @@ -273,7 +272,13 @@ "message_from_server": "Message du serveur: {{message}}", "video_has_finished_playing": "La vidéo a fini de jouer!", "no_video_source": "Aucune source vidéo...", - "next_episode": "Épisode suivant" + "next_episode": "Épisode suivant", + "refresh_tracks": "Rafraîchir les pistes", + "subtitle_tracks": "Pistes de sous-titres:", + "audio_tracks": "Pistes audio:", + "playback_state": "État de lecture:", + "no_data_available": "Aucune donnée disponible", + "index": "Index:" }, "item_card": { "next_up": "À suivre", @@ -299,12 +304,6 @@ "x_albums": "{{count}} albums", "artists": "Artistes", "could_not_load_item": "Impossible de charger l'item", - "refresh_tracks": "Rafraîchir les pistes", - "subtitle_tracks": "Pistes de sous-titres:", - "audio_tracks": "Pistes audio:", - "playback_state": "État de lecture:", - "no_data_available": "Aucune donnée disponible", - "index": "Index:", "download": { "download_season": "Télécharger la saison", "download_x_item": "Télécharger {{item_count}} items", @@ -313,6 +312,18 @@ "using_default_method": "Avec la méthode par défaut" } }, + "live_tv": { + "next": "Suivant", + "previous": "Précédent", + "live_tv": "TV en direct", + "coming_soon": "Bientôt", + "on_now": "En ce moment", + "shows": "Émissions", + "movies": "Films", + "sports": "Sports", + "for_kids": "Pour enfants", + "news": "Actualités" + }, "jellyseerr":{ "confirm": "Confirmer", "cancel": "Annuler", From 480abb216d89a525513300f73053bbce472cc9a0 Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Sun, 5 Jan 2025 16:07:55 -0500 Subject: [PATCH 25/34] fixes --- translations/en.json | 2 +- translations/fr.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/translations/en.json b/translations/en.json index 1dae952c..eb1425a7 100644 --- a/translations/en.json +++ b/translations/en.json @@ -171,7 +171,7 @@ "no_downloaded_items": "No downloaded items", "delete_all_movies_button": "Delete all Movies", "delete_all_tvseries_button": "Delete all TV-Series", - "delete_button": "Delete all", + "delete_all_button": "Delete all", "active_download": "Active download", "no_active_downloads": "No active downloads", "active_downloads": "Active downloads", diff --git a/translations/fr.json b/translations/fr.json index 24f22f83..dcbfbaf8 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -1,6 +1,6 @@ { "login": { - "username_required": "Le nom d'utilisateur est requis", + "username_required": "Nom d'utilisateur requis", "error_title": "Erreur", "login_title": "Se connecter", "login_to_title": "Se connecter à", From 14c8c1aaed62421b06bf0b8c137dca7f1b87f9c0 Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Tue, 7 Jan 2025 22:26:09 -0500 Subject: [PATCH 26/34] Fix some missing fields --- app/(auth)/(tabs)/(home)/intro/page.tsx | 26 ++++----- .../(home)/settings/marlin-search/page.tsx | 4 +- app/login.tsx | 6 +- components/SubtitleTrackSelector.tsx | 2 +- components/jellyseerr/discover/Slide.tsx | 3 +- components/settings/AudioToggles.tsx | 2 +- components/settings/SubtitleToggles.tsx | 2 +- providers/JellyfinProvider.tsx | 15 +++-- translations/en.json | 51 ++++++++++++++++- translations/fr.json | 55 +++++++++++++++++-- 10 files changed, 129 insertions(+), 37 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/intro/page.tsx b/app/(auth)/(tabs)/(home)/intro/page.tsx index cef9db31..59a315b0 100644 --- a/app/(auth)/(tabs)/(home)/intro/page.tsx +++ b/app/(auth)/(tabs)/(home)/intro/page.tsx @@ -6,9 +6,11 @@ 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"; export default function page() { const router = useRouter(); + const { t } = useTranslation(); useFocusEffect( useCallback(() => { @@ -20,18 +22,17 @@ export default function page() { - Welcome to Streamyfin + {t("home.intro.welcome_to_streamyfin")} - A free and open source client for Jellyfin. + {t("home.intro.a_free_and_open_source_client_for_jellyfin")} - Features + {t("home.intro.features_title")} - 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")} Jellyseerr - Connect to your Jellyseerr instance and request movies directly in - the app. + {t("home.intro.jellyseerr_feature_description")} @@ -60,11 +60,9 @@ export default function page() { - Downloads + {t("home.intro.downloads_feature_title")} - 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")} @@ -81,7 +79,7 @@ export default function page() { Chromecast - Cast movies and tv-shows to your Chromecast devices. + {t("home.intro.chromecast_feature_description")} @@ -93,7 +91,7 @@ export default function page() { }} className="mt-4" > - Done + {t("home.intro.done_button")} { @@ -102,7 +100,7 @@ export default function page() { }} className="mt-4" > - Go to settings + {t("home.intro.go_to_settings_button")} ); diff --git a/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx b/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx index b263d857..4a907dd3 100644 --- a/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx @@ -30,7 +30,7 @@ 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 = () => { @@ -82,7 +82,7 @@ export default function page() { - Change server + {t("login.change_server")} ) : null, }); @@ -100,9 +100,9 @@ const CredentialsSchema = z.object({ } } 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_unexpeted_error_occured")); } } finally { setLoading(false); diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 887a26c8..f389e453 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -56,7 +56,7 @@ export const SubtitleTrackSelector: React.FC = ({ {selectedSubtitleSteam ? tc(selectedSubtitleSteam?.DisplayTitle, 7) - : "None"} + : t("item_card.none")} diff --git a/components/jellyseerr/discover/Slide.tsx b/components/jellyseerr/discover/Slide.tsx index 5a593b41..f110eb15 100644 --- a/components/jellyseerr/discover/Slide.tsx +++ b/components/jellyseerr/discover/Slide.tsx @@ -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 = ({ return ( - {DiscoverSliderType[slide.type].toString().toTitle()} + {t("search." + DiscoverSliderType[slide.type].toString().toLowerCase())} = ({ ...props }) => { }); }} > - t("home.settings.audio.none") + {t("home.settings.audio.none")} {cultures?.map((l) => ( = ({ ...props }) => { - {settings?.defaultSubtitleLanguage?.DisplayName || "None"} + {settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")} = ({ const [jellyfin, setJellyfin] = useState(undefined); const [deviceId, setDeviceId] = useState(undefined); + const { t } = useTranslation(); + useEffect(() => { (async () => { const id = getOrSetDeviceId(); @@ -231,22 +234,22 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ if (axios.isAxiosError(error)) { switch (error.response?.status) { case 401: - throw new Error("Invalid username or password"); + throw new Error(t("login.invalid_username_or_password")); case 403: - throw new Error("User does not have permission to log in"); + throw new Error(t("login.user_does_not_have_permission_to_log_in")); case 408: throw new Error( - "Server is taking too long to respond, try again later" + t("login.server_is_taking_too_long_to_respond_try_again_later") ); case 429: throw new Error( - "Server received too many requests, try again later" + t("login.server_received_too_many_requests_try_again_later") ); case 500: - throw new Error("There is a server error"); + throw new Error(t("login.there_is_a_server_error")); default: throw new Error( - "An unexpected error occurred. Did you enter the server URL correctly?" + t("login.an_unexpected_error_occured_did_you_enter_the_correct_url") ); } } diff --git a/translations/en.json b/translations/en.json index 74a13001..df44273d 100644 --- a/translations/en.json +++ b/translations/en.json @@ -12,7 +12,15 @@ "failed_to_initiate_quick_connect": "Failed to initiate Quick Connect", "got_it": "Got it", "connection_failed": "Connection failed", - "could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection." + "could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.", + "an_unexpected_error_occured": "An unexpected error occurred", + "change_server": "Change server", + "invalid_username_or_password": "Invalid username or password", + "user_does_not_have_permission_to_log_in": "User does not have permission to log in", + "server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later", + "server_received_too_many_requests_try_again_later": "Server received too many requests, try again late.", + "there_is_a_server_error": "There is a server error", + "an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?" }, "server": { "enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server", @@ -33,6 +41,18 @@ "recently_added_in": "Recently Added in {{libraryName}}", "suggested_movies": "Suggested Movies", "suggested_episodes": "Suggested Episodes", + "intro": { + "welcome_to_streamyfin": "Welcome to Streamyfin", + "a_free_and_open_source_client_for_jellyfin": "A free and open-source client for Jellyfin.", + "features_title": "Features", + "features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:", + "jellyseerr_feature_description": "Connect to your Jellyseerr instance and request movies directly in the app.", + "downloads_feature_title": "Downloads", + "downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.", + "chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.", + "done_button": "Done", + "go_to_settings_button": "Go to settings" + }, "settings": { "settings_title": "Settings", "log_out_button": "Log out", @@ -121,7 +141,10 @@ "server_url_placeholder": "http(s)://domain.org:port", "marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.", "read_more_about_marlin": "Read more about Marlin.", - "save_button": "Save" + "save_button": "Save", + "toasts": { + "saved": "Saved" + } }, "popular_lists": { "enable_plugin": "Enable plugin", @@ -223,7 +246,28 @@ "albums": "Albums", "songs": "Songs", "request_movies": "Request Movies", - "request_series": "Request Series" + "request_series": "Request Series", + "recently_added": "Recently Added", + "recent_requests": "Recent Requests", + "plex_watchlist": "Plex Watchlist", + "trending": "Trending", + "popular_movies": "Popular Movies", + "movie_genres": "Movie Genres", + "upcoming_movies": "Upcoming Movies", + "studios": "Studios", + "popular_tv": "Popular TV", + "tv_genres": "TV Genres", + "upcoming_tv": "Upcoming TV", + "networks": "Networks", + "tmdb_movie_keyword": "TMDB Movie Keyword", + "tmdb_movie_genre": "TMDB Movie Genre", + "tmdb_tv_keyword": "TMDB TV Keyword", + "tmdb_tv_genre": "TMDB TV Genre", + "tmdb_search": "TMDB Search", + "tmdb_studio": "TMDB Studio", + "tmdb_network": "TMDB Network", + "tmdb_movie_streaming_services": "TMDB Movie Streaming Services", + "tmdb_tv_streaming_services": "TMDB TV Streaming Services" }, "library": { "no_items_found": "No items found", @@ -301,6 +345,7 @@ "x_albums": "{{count}} albums", "artists": "Artists", "could_not_load_item": "Could not load item", + "none": "None", "download": { "download_season": "Download Season", "download_x_item": "Download {{item_count}} items", diff --git a/translations/fr.json b/translations/fr.json index 02182ec2..a7b72412 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -12,7 +12,15 @@ "failed_to_initiate_quick_connect": "Échec de l'initialisation de Connexion Rapide", "got_it": "D'accord", "connection_failed": "La connection a échouée", - "could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier l'URL et votre connection réseau." + "could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier l'URL et votre connection réseau.", + "an_unexpected_error_occured": "Une erreur inattendue s'est produite", + "change_server": "Changer de serveur", + "invalid_username_or_password": "Nom d'utilisateur ou mot de passe invalide", + "user_does_not_have_permission_to_log_in": "L'utilisateur n'a pas la permission de se connecter", + "server_is_taking_too_long_to_respond_try_again_later": "Le serveur met trop de temps à répondre, réessayez plus tard", + "server_received_too_many_requests_try_again_later": "Le serveur a reçu trop de demandes, réessayez plus tard", + "there_is_a_server_error": "Il y a une erreur de serveur", + "an_unexpected_error_occured_did_you_enter_the_correct_url": "Une erreur inattendue s'est produite. Avez-vous entré la bonne URL?" }, "server": { "enter_url_to_jellyfin_server": "Entrez l'URL de votre serveur Jellyfin", @@ -33,6 +41,18 @@ "recently_added_in": "Ajoutés récemment dans {{libraryName}}", "suggested_movies": "Films suggérés", "suggested_episodes": "Épisodes suggérés", + "intro": { + "welcome_to_streamyfin": "Bienvenue sur Streamyfin", + "a_free_and_open_source_client_for_jellyfin": "Un client gratuit et open source pour Jellyfin", + "features_title": "Fonctionnalités", + "features_description": "Streamyfin possède de nombreuses fonctionnalités et s'intègre à un large éventail de logiciels que vous pouvez trouver dans le menu des paramètres, notamment:", + "jellyseerr_feature_description": "Connectez-vous à votre instance Jellyseerr et demandez des films directement dans l'application.", + "downloads_feature_title": "Téléchargements", + "downloads_feature_description": "Téléchargez des films et des émissions de télévision pour les regarder hors ligne. Utilisez la méthode par défaut ou installez le serveur d'optimisation pour télécharger les fichiers en arrière-plan.", + "chromecast_feature_description": "Diffusez des films et des émissions de télévision sur vos appareils Chromecast.", + "done_button": "Fait", + "go_to_settings_button": "Allez dans les paramètres" + }, "settings": { "settings_title": "Paramètres", "log_out_button": "Déconnexion", @@ -62,7 +82,7 @@ "set_audio_track": "Configurer la piste audio à partir de l'élément précédent", "audio_language": "Langue audio", "audio_hint": "Chosissez une langue audio par défaut.", - "none": "Aucun" + "none": "Aucune" }, "subtitles": { "subtitle_title": "Sous-titres", @@ -71,7 +91,7 @@ "set_subtitle_track": "Configurer la piste de sous-titres à partir de l'élément précédent", "subtitle_size": "Taille des sous-titres", "subtitle_hint": "Configurez les préférences des sous-titres.", - "none": "Aucun" + "none": "Aucune" }, "other": { "other_title": "Autres", @@ -121,7 +141,10 @@ "server_url_placeholder": "http(s)://domaine.org:port", "marlin_search_hint": "Entrez l'URL du serveur Marlin. L'URL devrait inclure http ou https et optionnellement le port.", "read_more_about_marlin": "Lisez-en plus sur Marlin.", - "save_button": "Enregistrer" + "save_button": "Enregistrer", + "toasts": { + "saved": "Enregistré" + } }, "popular_lists": { "enable_plugin": "Activer le plugiciel", @@ -223,7 +246,28 @@ "albums": "Albums", "songs": "Chansons", "request_movies": "Demander un film", - "request_series": "Demander une série" + "request_series": "Demander une série", + "recently_added": "Ajoutés récemment", + "recent_requests": "Demandes récentes", + "plex_watchlist": "Liste de lecture Plex", + "trending": "Tendance", + "popular_movies": "Films populaires", + "movie_genres": "Genres de films", + "upcoming_movies": "Films à venir", + "studios": "Studios", + "popular_tv": "TV populaire", + "tv_genres": "Genres TV", + "upcoming_tv": "TV à venir", + "networks": "Réseaux", + "tmdb_movie_keyword": "Mot-clé Films TMDB", + "tmdb_movie_genre": "Genre de film TMDB", + "tmdb_tv_keyword": "Mot-clé TV TMDB", + "tmdb_tv_genre": "Genre TV TMDB", + "tmdb_search": "Recherche TMDB", + "tmdb_studio": "Studio TMDB", + "tmdb_network": "Réseau TMDB", + "tmdb_movie_streaming_services": "Services de streaming de films TMDB", + "tmdb_tv_streaming_services": "Services de streaming TV TMDB" }, "library": { "no_items_found": "Aucun item trouvé", @@ -301,6 +345,7 @@ "x_albums": "{{count}} albums", "artists": "Artistes", "could_not_load_item": "Impossible de charger l'item", + "none": "Aucun", "download": { "download_season": "Télécharger la saison", "download_x_item": "Télécharger {{item_count}} items", From 580e12b6058e2d13f869667e7c2f58656db315d2 Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Sun, 12 Jan 2025 19:04:51 -0500 Subject: [PATCH 27/34] Alert --- app/login.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/login.tsx b/app/login.tsx index 3e5b916e..08c24075 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -102,7 +102,7 @@ const CredentialsSchema = z.object({ if (error instanceof Error) { Alert.alert(t("login.connection_failed"), error.message); } else { - Alert.alert(t("login.connection_failed"), t("login.an_unexpeted_error_occured")); + Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured")); } } finally { setLoading(false); From ea1f45bbafc12777748b5ff0e910c17ef20540b3 Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Sun, 12 Jan 2025 21:30:57 -0500 Subject: [PATCH 28/34] More settings + language component spacing --- app/(auth)/(tabs)/(favorites)/_layout.tsx | 3 +- app/(auth)/(tabs)/(home)/intro/page.tsx | 8 ++- app/(auth)/(tabs)/(home)/settings.tsx | 7 +-- .../(home)/settings/hide-libraries/page.tsx | 6 ++- components/JellyfinServerDiscovery.tsx | 6 ++- components/library/LibraryItemCard.tsx | 11 +++-- components/settings/AudioToggles.tsx | 4 +- components/settings/DownloadSettings.tsx | 6 +-- components/settings/MediaToggles.tsx | 4 +- components/settings/OtherSettings.tsx | 4 +- components/settings/PluginSettings.tsx | 2 +- components/settings/QuickConnect.tsx | 6 +-- components/settings/SubtitleToggles.tsx | 8 +-- translations/en.json | 41 +++++++++++++--- translations/fr.json | 49 ++++++++++++++----- 15 files changed, 110 insertions(+), 55 deletions(-) diff --git a/app/(auth)/(tabs)/(favorites)/_layout.tsx b/app/(auth)/(tabs)/(favorites)/_layout.tsx index b408eab6..b4c4452e 100644 --- a/app/(auth)/(tabs)/(favorites)/_layout.tsx +++ b/app/(auth)/(tabs)/(favorites)/_layout.tsx @@ -1,10 +1,9 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { Stack } from "expo-router"; import { Platform } from "react-native"; -import { useTranslation } from "react-i18next"; +import { t } from "i18next"; export default function SearchLayout() { - const { t } = useTranslation(); return ( - Centralised Settings Plugin + {t("home.intro.centralised_settings_plugin_title")} - Configure settings from a centralised location on your Jellyfin - server. All client settings for all users will be synced - automatically.{" "} + {t("home.intro.centralised_settings_plugin_description")}{" "} { @@ -107,7 +105,7 @@ export default function page() { ); }} > - Read more + {t("home.intro.read_more")} diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index bc17faf0..80a515dc 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -66,24 +66,25 @@ export default function settings() { - + + { router.push("/intro/page"); }} - title={"Show intro"} + title={t("home.settings.intro.show_intro")} /> { storage.set("hasShownIntro", false); }} - title={"Reset intro"} + title={t("home.settings.intro.reset_intro")} /> diff --git a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx index 35200bc1..5b96ddbc 100644 --- a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx @@ -8,6 +8,7 @@ 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() { @@ -15,6 +16,8 @@ export default function page() { const user = useAtomValue(userAtom); const api = useAtomValue(apiAtom); + const { t } = useTranslation(); + const { data, isLoading: isLoading } = useQuery({ queryKey: ["user-views", user?.Id], queryFn: async () => { @@ -57,8 +60,7 @@ export default function page() { ))} - 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")} ); diff --git a/components/JellyfinServerDiscovery.tsx b/components/JellyfinServerDiscovery.tsx index 5c310d64..dc2c46ad 100644 --- a/components/JellyfinServerDiscovery.tsx +++ b/components/JellyfinServerDiscovery.tsx @@ -4,6 +4,7 @@ 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; @@ -11,17 +12,18 @@ interface Props { const JellyfinServerDiscovery: React.FC = ({ onServerSelect }) => { const { servers, isSearching, startDiscovery } = useJellyfinDiscovery(); + const { t } = useTranslation(); return ( {servers.length ? ( - + {servers.map((server) => ( = ({ library, ...props }) => { const [user] = useAtom(userAtom); const [settings] = useSettings(); + const { t } = useTranslation(); + const url = useMemo( () => getPrimaryImageUrl({ @@ -69,13 +72,13 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => { let nameStr: string; if (library.CollectionType === "movies") { - nameStr = "movies"; + nameStr = t("library.item_types.movies"); } else if (library.CollectionType === "tvshows") { - nameStr = "series"; + nameStr = t("library.item_types.series"); } else if (library.CollectionType === "boxsets") { - nameStr = "box sets"; + nameStr = t("library.item_types.boxsets"); } else { - nameStr = "items"; + nameStr = t("library.item_types.items"); } return nameStr; diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx index c1f82d6c..44c1a2a8 100644 --- a/components/settings/AudioToggles.tsx +++ b/components/settings/AudioToggles.tsx @@ -47,7 +47,7 @@ export const AudioToggles: React.FC = ({ ...props }) => { - {settings?.defaultAudioLanguage?.DisplayName || "None"} + {settings?.defaultAudioLanguage?.DisplayName || t("home.settings.audio.none")} = ({ ...props }) => { collisionPadding={8} sideOffset={8} > - Languages + {t("home.settings.audio.language")} { diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx index 3199e049..8e233f38 100644 --- a/components/settings/DownloadSettings.tsx +++ b/components/settings/DownloadSettings.tsx @@ -61,7 +61,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => { collisionPadding={8} sideOffset={8} > - Methods + {t("home.settings.downloads.methods")} { @@ -69,7 +69,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => { setProcesses([]); }} > - Default + {t("home.settings.downloads.default")} { queryClient.invalidateQueries({ queryKey: ["search"] }); }} > - Optimized + {t("home.settings.downloads.optimized")} diff --git a/components/settings/MediaToggles.tsx b/components/settings/MediaToggles.tsx index a187f6e0..ae431ffb 100644 --- a/components/settings/MediaToggles.tsx +++ b/components/settings/MediaToggles.tsx @@ -37,7 +37,7 @@ export const MediaToggles: React.FC = ({ ...props }) => { value={settings.forwardSkipTime} disabled={pluginSettings?.forwardSkipTime?.locked} step={5} - appendValue="s" + appendValue={t("home.settings.media_controls.seconds_unit")} min={0} max={60} onUpdate={(forwardSkipTime) => updateSettings({forwardSkipTime})} @@ -52,7 +52,7 @@ export const MediaToggles: React.FC = ({ ...props }) => { value={settings.rewindSkipTime} disabled={pluginSettings?.rewindSkipTime?.locked} step={5} - appendValue="s" + appendValue={t("home.settings.media_controls.seconds_unit")} min={0} max={60} onUpdate={(rewindSkipTime) => updateSettings({rewindSkipTime})} diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx index ce772bbe..a7249d6c 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -147,11 +147,11 @@ export const OtherSettings: React.FC = () => { router.push("/settings/hide-libraries/page")} - title="Hide Libraries" + title={t("home.settings.other.hide_libraries")} showArrow /> { if (!settings) return null; return ( - + router.push("/settings/jellyseerr/page")} title={"Jellyseerr"} diff --git a/components/settings/QuickConnect.tsx b/components/settings/QuickConnect.tsx index 774935d9..c3559b62 100644 --- a/components/settings/QuickConnect.tsx +++ b/components/settings/QuickConnect.tsx @@ -65,7 +65,7 @@ export const QuickConnect: React.FC = ({ ...props }) => { return ( - + bottomSheetModalRef?.current?.present()} title={t("home.settings.quick_connect.authorize_button")} @@ -96,7 +96,7 @@ export const QuickConnect: React.FC = ({ ...props }) => { = ({ ...props }) => { onPress={authorizeQuickConnect} color="purple" > - Authorize + {t("home.settings.quick_connect.authorize")} diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index c84e058e..a22fcc99 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -43,7 +43,7 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { > item?.ThreeLetterISOLanguageName ?? "unknown"} titleExtractor={(item) => item?.DisplayName} title={ @@ -58,7 +58,7 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { /> } - label="Languages" + label={t("home.settings.subtitles.language")} onSelected={(defaultSubtitleLanguage) => updateSettings({ defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === t("home.settings.subtitles.none") @@ -81,7 +81,7 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { title={ - {settings?.subtitleMode || "Loading"} + {settings?.subtitleMode || t("home.settings.subtitles.loading")} = ({ ...props }) => { /> } - label="Subtitle Mode" + label={t("home.settings.subtitles.subtitle_mode")} onSelected={(subtitleMode) => updateSettings({subtitleMode}) } diff --git a/translations/en.json b/translations/en.json index 3e12745d..d64a3f8b 100644 --- a/translations/en.json +++ b/translations/en.json @@ -27,7 +27,10 @@ "server_url_placeholder": "http(s)://your-server.com", "connect_button": "Connect", "previous_servers": "previous servers", - "clear_button": "Clear" + "clear_button": "Clear", + "search_for_local_servers": "Search for local servers", + "searching": "Searching...", + "servers": "Servers" }, "home": { "no_internet": "No Internet", @@ -50,8 +53,11 @@ "downloads_feature_title": "Downloads", "downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.", "chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.", + "centralised_settings_plugin_title": "Centralised Settings Plugin", + "centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.", "done_button": "Done", - "go_to_settings_button": "Go to settings" + "go_to_settings_button": "Go to settings", + "read_more": "Read more" }, "settings": { "settings_title": "Settings", @@ -66,23 +72,26 @@ "quick_connect": { "quick_connect_title": "Quick Connect", "authorize_button": "Authorize Quick Connect", - "enter_the_quick_connect_code": "Enter the Quick Connect code", + "enter_the_quick_connect_code": "Enter the quick connect code...", "success": "Success", "quick_connect_autorized": "Quick Connect authorized", "error": "Error", - "invalid_code": "Invalid code" + "invalid_code": "Invalid code", + "authorize": "Authorize" }, "media_controls": { "media_controls_title": "Media Controls", "forward_skip_length": "Forward skip length", - "rewind_length": "Rewind length" + "rewind_length": "Rewind length", + "seconds_unit": "s" }, "audio": { "audio_title": "Audio", "set_audio_track": "Set Audio Track From Previous Item", "audio_language": "Audio language", "audio_hint": "Choose a default audio language.", - "none": "None" + "none": "None", + "language": "Language" }, "subtitles": { "subtitle_title": "Subtitles", @@ -91,14 +100,19 @@ "set_subtitle_track": "Set Subtitle Track From Previous Item", "subtitle_size": "Subtitle Size", "subtitle_hint": "Configure subtitle preference.", - "none": "None" + "none": "None", + "language": "Language", + "loading": "Loading" }, "other": { "other_title": "Other", "auto_rotate": "Auto rotate", "video_orientation": "Video orientation", "safe_area_in_controls": "Safe area in controls", - "show_custom_menu_links": "Show Custom Menu Links" + "show_custom_menu_links": "Show Custom Menu Links", + "hide_libraries": "Hide Libraries", + "select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.", + "disable_haptic_feedback": "Disable Haptic Feedback" }, "downloads": { "downloads_title": "Downloads", @@ -162,6 +176,10 @@ "size_used": "{{used}} of {{total}} used", "delete_all_downloaded_files": "Delete All Downloaded Files" }, + "intro": { + "show_intro": "Show intro", + "reset_intro": "Reset intro" + }, "logs": { "logs_title": "Logs", "no_logs_available": "No logs available", @@ -203,6 +221,7 @@ "something_went_wrong": "Something went wrong", "could_not_get_stream_url_from_jellyfin": "Could not get stream URL from Jellyfin", "eta": "ETA {{eta}}", + "methods": "Methods", "toasts": { "you_are_not_allowed_to_download_files": "You are not allowed to download files.", "deleted_all_movies_successfully": "Deleted all movies successfully!", @@ -270,6 +289,12 @@ "no_items_found": "No items found", "no_results": "No results", "no_libraries_found": "No libraries found", + "item_types": { + "movies": "movies", + "series": "series", + "boxsets": "box sets", + "items": "items" + }, "options": { "display": "Display", "row": "Row", diff --git a/translations/fr.json b/translations/fr.json index 777f87ee..461c1e37 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -27,7 +27,10 @@ "server_url_placeholder": "http(s)://votre-serveur.com", "connect_button": "Connexion", "previous_servers": "Serveurs précédents", - "clear_button": "Effacer" + "clear_button": "Effacer", + "search_for_local_servers": "Rechercher des serveurs locaux", + "searching": "Recherche...", + "servers": "Serveurs" }, "home": { "no_internet": "Pas d'Internet", @@ -50,8 +53,11 @@ "downloads_feature_title": "Téléchargements", "downloads_feature_description": "Téléchargez des films et des émissions de télévision pour les regarder hors ligne. Utilisez la méthode par défaut ou installez le serveur d'optimisation pour télécharger les fichiers en arrière-plan.", "chromecast_feature_description": "Diffusez des films et des émissions de télévision sur vos appareils Chromecast.", + "centralised_settings_plugin_title": "Plugin de paramètres centralisés", + "centralised_settings_plugin_description": "Configuration des paramètres d'un emplacement centralisé sur votre serveur Jellyfin. Tous les paramètres clients pour tous les utilisateurs seront synchronisés automatiquement.", "done_button": "Fait", - "go_to_settings_button": "Allez dans les paramètres" + "go_to_settings_button": "Allez dans les paramètres", + "read_more": "Lisez-en plus" }, "settings": { "settings_title": "Paramètres", @@ -66,23 +72,26 @@ "quick_connect": { "quick_connect_title": "Connexion Rapide", "authorize_button": "Autoriser Connexion Rapide", - "enter_the_quick_connect_code": "Entrez le code Connexion Rapide", + "enter_the_quick_connect_code": "Entrez le code Connexion Rapide...", "success": "Succès", "quick_connect_autorized": "Connexion Rapide autorisé", - "error": "Errur", - "invalid_code": "Code invalide" + "error": "Erreur", + "invalid_code": "Code invalide", + "authorize": "Autoriser" }, "media_controls": { "media_controls_title": "Contrôles Média", "forward_skip_length": "Durée de saut en avant", - "rewind_length": "Durée de retour arrière" + "rewind_length": "Durée de retour arrière", + "seconds_unit": "s" }, "audio": { "audio_title": "Audio", "set_audio_track": "Configurer la piste audio à partir de l'élément précédent", "audio_language": "Langue audio", "audio_hint": "Chosissez une langue audio par défaut.", - "none": "Aucune" + "none": "Aucune", + "language": "Langage" }, "subtitles": { "subtitle_title": "Sous-titres", @@ -91,14 +100,19 @@ "set_subtitle_track": "Configurer la piste de sous-titres à partir de l'élément précédent", "subtitle_size": "Taille des sous-titres", "subtitle_hint": "Configurez les préférences des sous-titres.", - "none": "Aucune" + "none": "Aucune", + "language": "Langage", + "loading": "Chargement" }, "other": { "other_title": "Autres", "auto_rotate": "Rotation automatique", "video_orientation": "Orientation vidéo", "safe_area_in_controls": "Zone de sécurité dans les contrôles", - "show_custom_menu_links": "Afficher les liens personnalisés" + "show_custom_menu_links": "Afficher les liens personnalisés", + "hide_libraries": "Cacher des bibliothèques", + "select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez obtenir de la table de bibliothèque et de la page d'accueil des sections.", + "disable_haptic_feedback": "Désactiver le retour haptique" }, "downloads": { "downloads_title": "Téléchargements", @@ -109,7 +123,7 @@ "save_button": "Enregistrer", "optimized_server": "Serveur optimisé", "optimized": "Optimisé", - "default": "Défaut", + "default": "Par défaut", "optimized_version_hint": "Entrez l'URL du serveur de versions optimisées. L'URL devrait inclure http ou https et optionnellement le port.", "read_more_about_optimized_server": "Lisez-en plus sur le serveur de versions optimisées.", "url": "URL", @@ -121,7 +135,7 @@ "jellyseerr_warning": "Cette intégration est dans ses débuts. Attendez-vous à ce que des choses changent.", "server_url": "URL du serveur", "server_url_hint": "Exemple: http(s)://votre-domaine.url\n(ajouter le port si nécessaire)", - "server_url_placeholder": "URL Jellyseerr...", + "server_url_placeholder": "URL de Jellyseerr...", "password": "Mot de passe", "password_placeholder": "Entrez le mot de passe pour l'utilisateur Jellyfin {{username}}", "save_button": "Enregistrer", @@ -159,9 +173,13 @@ "storage_title": "Stockage", "app_usage": "App {{usedSpace}}%", "phone_usage": "Téléphone {{availableSpace}}%", - "size_used": "{{used}} de {{total}} utilisé", + "size_used": "{{used}} de {{total}} utilisés", "delete_all_downloaded_files": "Supprimer tous les fichiers téléchargés" }, + "intro": { + "show_intro": "Afficher l'intro", + "reset_intro": "Réinitialiser l'intro" + }, "logs": { "logs_title": "Journaux", "no_logs_available": "Aucun journal disponible", @@ -203,6 +221,7 @@ "something_went_wrong": "Quelque chose s'est mal passé", "could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin", "eta": "ETA {{eta}}", + "methods": "Méthodes", "toasts": { "you_are_not_allowed_to_download_files": "Vous n'êtes pas autorisé à télécharger des fichiers", "deleted_all_movies_successfully": "Tous les films ont été supprimés avec succès!", @@ -270,6 +289,12 @@ "no_items_found": "Aucun item trouvé", "no_results": "Aucun résultat", "no_libraries_found": "Aucune bibliothèque trouvée", + "item_types": { + "movies": "films", + "series": "séries", + "boxsets": "coffrets", + "items": "items" + }, "options": { "display": "Affichage", "row": "Rangée", From 90f20f6e46de133605e6f6980c260838118a6a89 Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Sun, 12 Jan 2025 21:34:08 -0500 Subject: [PATCH 29/34] Shorter messages --- translations/fr.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/translations/fr.json b/translations/fr.json index 461c1e37..2ca9a929 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -87,7 +87,7 @@ }, "audio": { "audio_title": "Audio", - "set_audio_track": "Configurer la piste audio à partir de l'élément précédent", + "set_audio_track": "Piste audio de l'élément précédent", "audio_language": "Langue audio", "audio_hint": "Chosissez une langue audio par défaut.", "none": "Aucune", @@ -97,7 +97,7 @@ "subtitle_title": "Sous-titres", "subtitle_language": "Langue des sous-titres", "subtitle_mode": "Mode des sous-titres", - "set_subtitle_track": "Configurer la piste de sous-titres à partir de l'élément précédent", + "set_subtitle_track": "Piste de sous-titres de l'élément précédent", "subtitle_size": "Taille des sous-titres", "subtitle_hint": "Configurez les préférences des sous-titres.", "none": "Aucune", From cd8aba32d8ade2e71ef497761ea68abecf6596da Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Mon, 13 Jan 2025 00:03:41 -0500 Subject: [PATCH 30/34] Jellyseerr --- app/(auth)/(tabs)/(favorites)/_layout.tsx | 3 +- .../jellyseerr/person/[personId].tsx | 7 +++-- .../series/[id].tsx | 4 ++- components/DownloadItem.tsx | 8 +++-- components/jellyseerr/Cast.tsx | 4 ++- components/jellyseerr/DetailFacts.tsx | 30 ++++++++++--------- components/jellyseerr/RequestModal.tsx | 25 +++++++++------- components/series/JellyseerrSeasons.tsx | 4 +-- translations/en.json | 28 +++++++++++++++++ translations/fr.json | 28 +++++++++++++++++ 10 files changed, 106 insertions(+), 35 deletions(-) diff --git a/app/(auth)/(tabs)/(favorites)/_layout.tsx b/app/(auth)/(tabs)/(favorites)/_layout.tsx index b4c4452e..b408eab6 100644 --- a/app/(auth)/(tabs)/(favorites)/_layout.tsx +++ b/app/(auth)/(tabs)/(favorites)/_layout.tsx @@ -1,9 +1,10 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { Stack } from "expo-router"; import { Platform } from "react-native"; -import { t } from "i18next"; +import { useTranslation } from "react-i18next"; export default function SearchLayout() { + const { t } = useTranslation(); return ( item.id.toString()} logo={ - Born{" "} + {t("jellyseerr.born")}{" "} {new Date(data?.details?.birthday!!).toLocaleDateString( `${locale}-${region}`, { diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx index 2758010b..a62405e1 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx @@ -16,9 +16,11 @@ import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import React, { useEffect, useMemo } from "react"; import { View } from "react-native"; +import { useTranslation } from "react-i18next"; const page: React.FC = () => { const navigation = useNavigation(); + const { t } = useTranslation(); const params = useLocalSearchParams(); const { id: seriesId, seasonIndex } = params as { id: string; @@ -85,7 +87,7 @@ const page: React.FC = () => { ( diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index eede13f5..dcb14128 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -32,7 +32,7 @@ import { MediaSourceSelector } from "./MediaSourceSelector"; import ProgressCircle from "./ProgressCircle"; import { RoundButton } from "./RoundButton"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; -import { useTranslation } from "react-i18next"; +import { t } from "i18next"; interface DownloadProps extends ViewProps { items: BaseItemDto[]; @@ -56,7 +56,7 @@ export const DownloadItems: React.FC = ({ const [user] = useAtom(userAtom); const [queue, setQueue] = useAtom(queueAtom); const [settings] = useSettings(); - const { t } = useTranslation(); + const { processes, startBackgroundDownload, downloadedFiles } = useDownload(); const { startRemuxing } = useRemuxHlsToMp4(); @@ -393,7 +393,9 @@ export const DownloadSingleItem: React.FC<{ return ( ( diff --git a/components/jellyseerr/Cast.tsx b/components/jellyseerr/Cast.tsx index fd6fc753..8dcdd785 100644 --- a/components/jellyseerr/Cast.tsx +++ b/components/jellyseerr/Cast.tsx @@ -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 && ( - Cast + {t("jellyseerr.cast")} = ({ 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 && ( - Details + {t("jellyseerr.details")} - + {details.keywords.some( (keyword) => keyword.id === ANIME_KEYWORD_ID - ) && } + ) && } ( {r.type === 3 ? ( @@ -184,13 +186,13 @@ const DetailFacts: React.FC< ))} /> - - - - - + + + + + ( @@ -199,14 +201,14 @@ const DetailFacts: React.FC< ))} /> n.name )} /> - n.name)} /> + n.name)} /> s.name)} /> diff --git a/components/jellyseerr/RequestModal.tsx b/components/jellyseerr/RequestModal.tsx index 2f735bd7..1f7180e7 100644 --- a/components/jellyseerr/RequestModal.tsx +++ b/components/jellyseerr/RequestModal.tsx @@ -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(); const {data: serviceSettings} = useQuery({ @@ -103,7 +106,7 @@ const RequestModal = forwardRef 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 - Advanced + {t("jellyseerr.advanced")} {seasonTitle && {seasonTitle} } @@ -161,27 +164,27 @@ const RequestModal = forwardRef 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")} /> 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")} /> 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")} /> 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 - Request + {t("jellyseerr.request_button")} diff --git a/components/series/JellyseerrSeasons.tsx b/components/series/JellyseerrSeasons.tsx index 41afafdb..320043b2 100644 --- a/components/series/JellyseerrSeasons.tsx +++ b/components/series/JellyseerrSeasons.tsx @@ -256,8 +256,8 @@ const JellyseerrSeasons: React.FC<{ {[0].map(() => { diff --git a/translations/en.json b/translations/en.json index d64a3f8b..e35b55b6 100644 --- a/translations/en.json +++ b/translations/en.json @@ -365,6 +365,9 @@ "none": "None", "download": { "download_season": "Download Season", + "download_series": "Download Series", + "download_episode": "Download Episode", + "download_movie": "Download Movie", "download_x_item": "Download {{item_count}} items", "download_button": "Download", "using_optimized_server": "Using optimized server", @@ -397,6 +400,31 @@ "request_button": "Request", "are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?", "failed_to_login": "Failed to login", + "cast": "Cast", + "details": "Details", + "status": "Status", + "original_title": "Original Title", + "series_type": "Series Type", + "release_dates": "Release Dates", + "first_air_date": "First Air Date", + "next_air_date": "Next Air Date", + "revenue": "Revenue", + "budget": "Budget", + "original_language": "Original Language", + "production_country": "Production Country", + "studios": "Studios", + "network": "Network", + "currently_streaming_on": "Currently Streaming on", + "advanced": "Advanced", + "request_as": "Request As", + "tags": "Tags", + "quality_profile": "Quality Profile", + "root_folder": "Root Folder", + "season_x": "Season {{seasons}}", + "season_number": "Season {{season_number}}", + "number_episodes": "{{episode_number}} Episodes", + "born": "Born", + "appearances": "Appearances", "toasts": { "jellyseer_does_not_meet_requirements": "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0", "jellyseerr_test_failed": "Jellyseerr test failed. Please try again.", diff --git a/translations/fr.json b/translations/fr.json index 2ca9a929..c3b4001b 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -365,6 +365,9 @@ "none": "Aucun", "download": { "download_season": "Télécharger la saison", + "download_series": "Télécharger la série", + "download_episode": "Télécharger l'épisode", + "download_movie": "Télécharger le film", "download_x_item": "Télécharger {{item_count}} items", "download_button": "Télécharger", "using_optimized_server": "Avec le serveur de versions optimisées", @@ -397,6 +400,31 @@ "request_button": "Demander", "are_you_sure_you_want_to_request_all_seasons": "Êtes-vous sûr de vouloir demander toutes les saisons?", "failed_to_login": "Échec de la connexion", + "cast": "Distribution", + "details": "Détails", + "status": "Statut", + "original_title": "Titre original", + "series_type": "Type de série", + "release_dates": "Dates de sortie", + "first_air_date": "Date de première diffusion", + "next_air_date": "Date de prochaine diffusion", + "revenue": "Revenu", + "budget": "Budget", + "original_language": "Langue originale", + "production_country": "Pays de production", + "studios": "Studios", + "network": "Réseaux", + "currently_streaming_on": "En diffusion continue sur", + "advanced": "Avancé", + "request_as": "Demander en tant que", + "tags": "Tags", + "quality_profile": "Profil de qualité", + "root_folder": "Dossier racine", + "season_x": "Saison {{seasons}}", + "season_number": "Saison {{season_number}}", + "number_episodes": "{{episode_number}} épisodes", + "born": "Né(e) le", + "appearances": "Apparitions", "toasts": { "jellyseer_does_not_meet_requirements": "Jellyseer ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.", "jellyseerr_test_failed": "Échec du test de Jellyseerr", From 5703279b4674f787432526e253594fb6a6a8ec6e Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Mon, 13 Jan 2025 21:18:37 -0500 Subject: [PATCH 31/34] Merge develop --- .github/ISSUE_TEMPLATE/bug_report.yml | 3 - app/(auth)/(tabs)/(home)/_layout.tsx | 6 + app/(auth)/(tabs)/(home)/index.tsx | 257 +++++++++--------- .../(home)/settings/popular-lists/page.tsx | 154 ----------- components/settings/PluginSettings.tsx | 5 - translations/en.json | 8 - translations/fr.json | 8 - utils/atoms/settings.ts | 169 +++++++----- utils/log.tsx | 34 +-- 9 files changed, 258 insertions(+), 386 deletions(-) delete mode 100644 app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6d36f734..a38e5f84 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -46,11 +46,8 @@ body: - 0.25.0 - 0.24.0 - 0.23.0 -<<<<<<< Updated upstream -======= - 0.22.0 - 0.21.0 ->>>>>>> Stashed changes - older validations: required: true diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 4ed55a24..8ee52292 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -85,6 +85,12 @@ export default function IndexLayout() { title: "", }} /> + { - 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 ( @@ -214,107 +191,139 @@ export default function index() { [api, user?.Id] ); - const sections = useMemo(() => { - if (!api || !user?.Id) return []; + let sections: Section[] = []; + if (settings?.home === null || settings?.home?.sections === null) { + sections = useMemo(() => { + if (!api || !user?.Id) return []; - const latestMediaViews = collections.map((c) => { - const includeItemTypes: BaseItemKind[] = - c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; - const title = t("home.recently_added_in", {libraryName: c.Name}); - const queryKey = [ - "home", - "recentlyAddedIn" + c.CollectionType, - user?.Id!, - c.Id!, - ]; - return createCollectionConfig( - title || "", - queryKey, - includeItemTypes, - c.Id - ); - }); + const latestMediaViews = collections.map((c) => { + const includeItemTypes: BaseItemKind[] = + c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; + const title = t("home.recently_added_in", {libraryName: c.Name}); + const queryKey = [ + "home", + "recentlyAddedIn" + c.CollectionType, + user?.Id!, + c.Id!, + ]; + return createCollectionConfig( + title || "", + queryKey, + includeItemTypes, + c.Id + ); + }); - const ss: Section[] = [ - { - title: t("home.continue_watching"), - queryKey: ["home", "resumeItems"], - queryFn: async () => - ( - await getItemsApi(api).getResumeItems({ - userId: user.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - includeItemTypes: ["Movie", "Series", "Episode"], - }) - ).data.Items || [], - type: "ScrollingCollectionList", - orientation: "horizontal", - }, - { - title: t("home.next_up"), - queryKey: ["home", "nextUp-all"], - queryFn: async () => - ( - await getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount"], - limit: 20, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - enableResumable: false, - }) - ).data.Items || [], - type: "ScrollingCollectionList", - orientation: "horizontal", - }, - ...latestMediaViews, - ...(mediaListCollections?.map( - (ml) => - ({ - title: ml.Name, - queryKey: ["home", "mediaList", ml.Id!], - queryFn: async () => ml, - type: "MediaListSection", - orientation: "vertical", - } as Section) - ) || []), - { - title: t("home.suggested_movies"), - queryKey: ["home", "suggestedMovies", user?.Id], - queryFn: async () => - ( - await getSuggestionsApi(api).getSuggestions({ - userId: user?.Id, - limit: 10, - mediaType: ["Video"], - type: ["Movie"], - }) - ).data.Items || [], - type: "ScrollingCollectionList", - orientation: "vertical", - }, - { - title: t("home.suggested_episodes"), - queryKey: ["home", "suggestedEpisodes", user?.Id], - queryFn: async () => { - try { - const suggestions = await getSuggestions(api, user.Id); - const nextUpPromises = suggestions.map((series) => - getNextUp(api, user.Id, series.Id) - ); - const nextUpResults = await Promise.all(nextUpPromises); - - return nextUpResults.filter((item) => item !== null) || []; - } catch (error) { - console.error("Error fetching data:", error); - return []; - } + 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]; + ss.push({ + title: key, + queryKey: ["home", key, user?.Id], + queryFn: async () => + ( + await getItemsApi(api).getItems({ + userId: user?.Id, + limit: section.items?.limit || 20, + recursive: true, + includeItemTypes: section.items?.includeItemTypes, + sortBy: section.items?.sortBy, + sortOrder: section.items?.sortOrder, + filters: section.items?.filters, + parentId: section.items?.parentId, + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: section?.orientation || "vertical", + }); + } + return ss; + }, [api, user?.Id, settings.home?.sections]); + } if (isConnected === false) { return ( @@ -358,7 +367,7 @@ export default function index() { ); } - if (e1 || e2) + if (e1) return ( {t("home.oops")} @@ -366,7 +375,7 @@ export default function index() { ); - if (l1 || l2) + if (l1) return ( diff --git a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx b/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx deleted file mode 100644 index c44146db..00000000 --- a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx +++ /dev/null @@ -1,154 +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 DisabledSetting from "@/components/settings/DisabledSetting"; -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 { useAtom } from "jotai"; -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Linking, Switch } from "react-native"; - -export default function page() { - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - - const { t } = useTranslation(); - - const [settings, updateSettings, pluginSettings] = 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, - }); - - const disabled = useMemo( - () => - pluginSettings?.usePopularPlugin?.locked === true && - pluginSettings?.mediaListCollectionIds?.locked === true, - [pluginSettings] - ); - - if (!settings) return null; - - return ( - - - { - updateSettings({ usePopularPlugin: true }); - queryClient.invalidateQueries({ queryKey: ["search"] }); - }} - > - - updateSettings({ usePopularPlugin }) - } - /> - - - - {t("home.settings.plugins.popular_lists.enable_popular_hint")}{" "} - - {t("home.settings.plugins.popular_lists.read_more_about_popular_lists")} - - - - {settings.usePopularPlugin && ( - <> - {!isLoadingMediaListCollections ? ( - <> - {mediaListCollections?.length === 0 ? ( - - {t("home.settings.plugins.popular_lists.no_collections_found")} - - ) : ( - <> - - {mediaListCollections?.map((mlc) => ( - - { - if (!settings.mediaListCollectionIds) { - updateSettings({ - mediaListCollectionIds: [mlc.Id!], - }); - return; - } - - updateSettings({ - mediaListCollectionIds: - settings.mediaListCollectionIds.includes( - mlc.Id! - ) - ? settings.mediaListCollectionIds.filter( - (id) => id !== mlc.Id - ) - : [ - ...settings.mediaListCollectionIds, - mlc.Id!, - ], - }); - }} - /> - - ))} - - - {t("home.settings.plugins.popular_lists.select_the_lists_you_want_to_display")} - - - )} - - ) : ( - - )} - - )} - - ); -} diff --git a/components/settings/PluginSettings.tsx b/components/settings/PluginSettings.tsx index deb30cf4..caac1e33 100644 --- a/components/settings/PluginSettings.tsx +++ b/components/settings/PluginSettings.tsx @@ -27,11 +27,6 @@ export const PluginSettings = () => { title="Marlin Search" showArrow /> - router.push("/settings/popular-lists/page")} - title="Popular Lists" - showArrow - /> ); diff --git a/translations/en.json b/translations/en.json index e35b55b6..c73ebe23 100644 --- a/translations/en.json +++ b/translations/en.json @@ -159,14 +159,6 @@ "toasts": { "saved": "Saved" } - }, - "popular_lists": { - "enable_plugin": "Enable plugin", - "enable_popular_lists": "Enable Popular Lists", - "enable_popular_hint": "Popular Lists is a plugin that enables you to show custom Jellyfin lists on the Streamyfin home page.", - "read_more_about_popular_lists": "Read more about Popular Lists.", - "no_collections_found": "No collections found. Add some in Jellyfin.", - "select_the_lists_you_want_to_display": "Select the lists you want displayed on the home screen." } }, "storage": { diff --git a/translations/fr.json b/translations/fr.json index c3b4001b..aa0ae115 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -159,14 +159,6 @@ "toasts": { "saved": "Enregistré" } - }, - "popular_lists": { - "enable_plugin": "Activer le plugiciel", - "enable_popular_lists": "Activer Popular Lists", - "enable_popular_hint": "Popular Lists est un plugiciel qui affiche des listes populaires sur l'écran d'accueil.", - "read_more_about_popular_lists": "Lisez-en plus sur Popular Lists.", - "no_collections_found": "Aucune collection trouvée. Ajoutez-en dans Jellyfin.", - "select_the_lists_you_want_to_display": "Sélectionnez les listes que vous voulez afficher sur l'écran d'accueil." } }, "storage": { diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 7f9bba1e..527b3d63 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -1,19 +1,21 @@ import { atom, useAtom } from "jotai"; -import {useCallback, useEffect, useMemo} from "react"; +import { useCallback, useEffect, useMemo } from "react"; import * as ScreenOrientation from "expo-screen-orientation"; import { storage } from "../mmkv"; import { Platform } from "react-native"; import { CultureDto, - PluginStatus, SubtitlePlaybackMode, + ItemSortBy, + SortOrder, + BaseItemKind, + ItemFilter, } from "@jellyfin/sdk/lib/generated-client"; -import {apiAtom} from "@/providers/JellyfinProvider"; -import {getPluginsApi} from "@jellyfin/sdk/lib/utils/api"; -import {writeErrorLog} from "@/utils/log"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { writeInfoLog } from "@/utils/log"; -const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004" -const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS" +const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004"; +const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS"; export type DownloadQuality = "original" | "high" | "low"; @@ -68,13 +70,32 @@ export type DefaultLanguageOption = { export enum DownloadMethod { Remux = "remux", - Optimized = "optimized" + Optimized = "optimized", } +export type Home = { + sections: [Object]; +}; + +export type HomeSection = { + orientation?: "horizontal" | "vertical"; + items?: HomeSectionItemResolver; +}; + +export type HomeSectionItemResolver = { + sortBy?: Array; + sortOrder?: Array; + includeItemTypes?: Array; + genres?: Array; + parentId?: string; + limit?: number; + filters?: Array; +}; + export type Settings = { + home?: Home | null; autoRotate?: boolean; forceLandscapeInVideoPlayer?: boolean; - usePopularPlugin?: boolean; deviceProfile?: "Expo" | "Native" | "Old"; mediaListCollectionIds?: string[]; preferedLanguage?: string; @@ -107,19 +128,21 @@ export type Settings = { export interface Lockable { locked: boolean; - value: T + value: T; } -export type PluginLockableSettings = { [K in keyof Settings]: Lockable }; +export type PluginLockableSettings = { + [K in keyof Settings]: Lockable; +}; export type StreamyfinPluginConfig = { - settings: PluginLockableSettings -} + settings: PluginLockableSettings; +}; const loadSettings = (): Settings => { const defaultValues: Settings = { + home: null, autoRotate: true, forceLandscapeInVideoPlayer: false, - usePopularPlugin: false, deviceProfile: "Expo", mediaListCollectionIds: [], preferedLanguage: undefined, @@ -174,7 +197,9 @@ const saveSettings = (settings: Settings) => { }; export const settingsAtom = atom(null); -export const pluginSettingsAtom = atom(storage.get(STREAMYFIN_PLUGIN_SETTINGS)); +export const pluginSettingsAtom = atom( + storage.get(STREAMYFIN_PLUGIN_SETTINGS) +); export const useSettings = () => { const [api] = useAtom(apiAtom); @@ -188,62 +213,25 @@ export const useSettings = () => { } }, [_settings, setSettings]); - const setPluginSettings = useCallback((settings: PluginLockableSettings | undefined) => { - storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings) - _setPluginSettings(settings) + const setPluginSettings = useCallback( + (settings: PluginLockableSettings | undefined) => { + storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings); + _setPluginSettings(settings); }, [_setPluginSettings] - ) + ); - const refreshStreamyfinPluginSettings = useCallback( - async () => { - if (!api) - return + const refreshStreamyfinPluginSettings = useCallback(async () => { + if (!api) return; + const settings = await api + .getStreamyfinPluginConfig() + .then(({ data }) => data?.settings); - const plugins = await getPluginsApi(api).getPlugins().then(({data}) => data); + writeInfoLog(`Got remote settings: ${JSON.stringify(settings)}`); - if (plugins && plugins.length > 0) { - const streamyfinPlugin = plugins.find(plugin => plugin.Id === STREAMYFIN_PLUGIN_ID); - - if (!streamyfinPlugin || streamyfinPlugin.Status != PluginStatus.Active) { - writeErrorLog( - "Streamyfin plugin is currently not active.\n" + - `Current status is: ${streamyfinPlugin?.Status}` - ); - setPluginSettings(undefined); - return; - } - - const settings = await api.getStreamyfinPluginConfig() - .then(({data}) => data.settings) - - setPluginSettings(settings); - return settings; - } - }, - [api] - ) - - // We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting. - // If admin sets locked to false but provides a value, - // use user settings first and fallback on admin setting if required. - const settings: Settings = useMemo(() => { - const overrideSettings = Object.entries(pluginSettings || {}) - .reduce((acc, [key, setting]) => { - if (setting) { - const {value, locked} = setting - acc = Object.assign(acc, { - [key]: locked ? value : _settings?.[key as keyof Settings] ?? value - }) - } - return acc - }, {} as Settings) - - return { - ..._settings, - ...overrideSettings - } - }, [_settings, setSettings, pluginSettings, _setPluginSettings, setPluginSettings]) + setPluginSettings(settings); + return settings; + }, [api]); const updateSettings = (update: Partial) => { if (settings) { @@ -254,5 +242,52 @@ export const useSettings = () => { } }; - return [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] as const; + // We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting. + // If admin sets locked to false but provides a value, + // use user settings first and fallback on admin setting if required. + const settings: Settings = useMemo(() => { + let unlockedPluginDefaults = {} as Settings; + const overrideSettings = Object.entries(pluginSettings || {}).reduce( + (acc, [key, setting]) => { + if (setting) { + const { value, locked } = setting; + + // Make sure we override default settings with plugin settings when they are not locked. + // Admin decided what users defaults should be and grants them the ability to change them too. + if ( + locked === false && + value && + _settings?.[key as keyof Settings] !== value + ) { + unlockedPluginDefaults = Object.assign(unlockedPluginDefaults, { + [key as keyof Settings]: value, + }); + } + + acc = Object.assign(acc, { + [key]: locked ? value : _settings?.[key as keyof Settings] ?? value, + }); + } + return acc; + }, + {} as Settings + ); + + // Update settings with plugin defined defaults + if (Object.keys(unlockedPluginDefaults).length > 0) { + updateSettings(unlockedPluginDefaults); + } + return { + ..._settings, + ...overrideSettings, + }; + }, [_settings, pluginSettings]); + + return [ + settings, + updateSettings, + pluginSettings, + setPluginSettings, + refreshStreamyfinPluginSettings, + ] as const; }; diff --git a/utils/log.tsx b/utils/log.tsx index 7c432406..45999062 100644 --- a/utils/log.tsx +++ b/utils/log.tsx @@ -1,7 +1,7 @@ import { atomWithStorage, createJSONStorage } from "jotai/utils"; import { storage } from "./mmkv"; -import {useQuery} from "@tanstack/react-query"; -import React, {createContext, useContext} from "react"; +import { useQuery } from "@tanstack/react-query"; +import React, { createContext, useContext } from "react"; type LogLevel = "INFO" | "WARN" | "ERROR"; @@ -19,10 +19,12 @@ const mmkvStorage = createJSONStorage(() => ({ })); const logsAtom = atomWithStorage("logs", [], mmkvStorage); -const LogContext = createContext | null>(null); -const DownloadContext = createContext | null>(null); +const LogContext = createContext | null>( + null +); +const DownloadContext = createContext | null>( + null +); function useLogProvider() { const { data: logs } = useQuery({ @@ -32,11 +34,10 @@ function useLogProvider() { }); return { - logs - } + logs, + }; } - export const writeToLog = (level: LogLevel, message: string, data?: any) => { const newEntry: LogEntry = { timestamp: new Date().toISOString(), @@ -53,10 +54,13 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => { const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0)); storage.set("logs", JSON.stringify(recentLogs)); + console.log(message); }; -export const writeInfoLog = (message: string, data?: any) => writeToLog("INFO", message, data); -export const writeErrorLog = (message: string, data?: any) => writeToLog("ERROR", message, data); +export const writeInfoLog = (message: string, data?: any) => + writeToLog("INFO", message, data); +export const writeErrorLog = (message: string, data?: any) => + writeToLog("ERROR", message, data); export const readFromLog = (): LogEntry[] => { const logs = storage.getString("logs"); @@ -75,14 +79,10 @@ export function useLog() { return context; } -export function LogProvider({children}: { children: React.ReactNode }) { +export function LogProvider({ children }: { children: React.ReactNode }) { const provider = useLogProvider(); - return ( - - {children} - - ) + return {children}; } export default logsAtom; From 364ce46fe5964cb9fb6e68851e72a5da3f5a2c10 Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Mon, 13 Jan 2025 22:30:57 -0500 Subject: [PATCH 32/34] Screen Orientation Enum + Subtitle Mode --- components/settings/OtherSettings.tsx | 6 +++--- components/settings/SubtitleToggles.tsx | 12 ++++++++++-- translations/en.json | 22 +++++++++++++++++++++- translations/fr.json | 22 +++++++++++++++++++++- utils/atoms/settings.ts | 20 ++++++++++---------- 5 files changed, 65 insertions(+), 17 deletions(-) diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx index a7249d6c..51c92717 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -98,17 +98,17 @@ export const OtherSettings: React.FC = () => { disabled={pluginSettings?.defaultVideoOrientation?.locked || settings.autoRotate} keyExtractor={String} titleExtractor={(item) => - ScreenOrientationEnum[item] + t(ScreenOrientationEnum[item]) } title={ - {ScreenOrientationEnum[settings.defaultVideoOrientation]} + {t(ScreenOrientationEnum[settings.defaultVideoOrientation])} } - label="Orientation" + label={t("home.settings.other.orientation")} onSelected={(defaultVideoOrientation) => updateSettings({defaultVideoOrientation}) } diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index a22fcc99..3748d0e5 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -31,6 +31,14 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { SubtitlePlaybackMode.None, ]; + const subtitleModeKeys = { + [SubtitlePlaybackMode.Default]: "home.settings.subtitles.modes.Default", + [SubtitlePlaybackMode.Smart]: "home.settings.subtitles.modes.Smart", + [SubtitlePlaybackMode.OnlyForced]: "home.settings.subtitles.modes.OnlyForced", + [SubtitlePlaybackMode.Always]: "home.settings.subtitles.modes.Always", + [SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None", + }; + return ( = ({ ...props }) => { data={subtitleModes} disabled={pluginSettings?.subtitleMode?.locked} keyExtractor={String} - titleExtractor={String} + titleExtractor={(item) => t(subtitleModeKeys[item]) || String(item)} title={ - {settings?.subtitleMode || t("home.settings.subtitles.loading")} + {t(subtitleModeKeys[settings?.subtitleMode]) || t("home.settings.subtitles.loading")} = { - [ScreenOrientation.OrientationLock.DEFAULT]: "Default", - [ScreenOrientation.OrientationLock.ALL]: "All", - [ScreenOrientation.OrientationLock.PORTRAIT]: "Portrait", - [ScreenOrientation.OrientationLock.PORTRAIT_UP]: "Portrait Up", - [ScreenOrientation.OrientationLock.PORTRAIT_DOWN]: "Portrait Down", - [ScreenOrientation.OrientationLock.LANDSCAPE]: "Landscape", - [ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "Landscape Left", - [ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: "Landscape Right", - [ScreenOrientation.OrientationLock.OTHER]: "Other", - [ScreenOrientation.OrientationLock.UNKNOWN]: "Unknown", + [ScreenOrientation.OrientationLock.DEFAULT]: "home.settings.other.orientations.DEFAULT", + [ScreenOrientation.OrientationLock.ALL]: "home.settings.other.orientations.ALL", + [ScreenOrientation.OrientationLock.PORTRAIT]: "home.settings.other.orientations.PORTRAIT", + [ScreenOrientation.OrientationLock.PORTRAIT_UP]: "home.settings.other.orientations.PORTRAIT_UP", + [ScreenOrientation.OrientationLock.PORTRAIT_DOWN]: "home.settings.other.orientations.PORTRAIT_DOWN", + [ScreenOrientation.OrientationLock.LANDSCAPE]: "home.settings.other.orientations.LANDSCAPE", + [ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "home.settings.other.orientations.LANDSCAPE_LEFT", + [ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: "home.settings.other.orientations.LANDSCAPE_RIGHT", + [ScreenOrientation.OrientationLock.OTHER]: "home.settings.other.orientations.OTHER", + [ScreenOrientation.OrientationLock.UNKNOWN]: "home.settings.other.orientations.UNKNOWN", }; export const DownloadOptions: DownloadOption[] = [ From cd9f6aa8bd9420b91420223efef27a9d7ab0f977 Mon Sep 17 00:00:00 2001 From: Simon Caron <8635747+simoncaron@users.noreply.github.com> Date: Tue, 14 Jan 2025 00:06:14 -0500 Subject: [PATCH 33/34] update submodule --- utils/jellyseerr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/jellyseerr b/utils/jellyseerr index a15f2ab3..4401b164 160000 --- a/utils/jellyseerr +++ b/utils/jellyseerr @@ -1 +1 @@ -Subproject commit a15f2ab336936f49e38ea37f8b224da40e12588e +Subproject commit 4401b16414af604a7372dacac326c38b18ad8555 From fdaa69a78736afba8a9d65571d7e1773f625013c Mon Sep 17 00:00:00 2001 From: Uruk Date: Tue, 14 Jan 2025 13:51:43 +0100 Subject: [PATCH 34/34] fix(i18n): missing typo and comma --- translations/en.json | 20 ++++++++++---------- translations/fr.json | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/translations/en.json b/translations/en.json index b06d71b5..df889eb3 100644 --- a/translations/en.json +++ b/translations/en.json @@ -18,7 +18,7 @@ "invalid_username_or_password": "Invalid username or password", "user_does_not_have_permission_to_log_in": "User does not have permission to log in", "server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later", - "server_received_too_many_requests_try_again_later": "Server received too many requests, try again late.", + "server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.", "there_is_a_server_error": "There is a server error", "an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?" }, @@ -108,7 +108,7 @@ "Smart": "Smart", "Always": "Always", "None": "None", - "OnlyForced": "OnlyForced" + "OnlyForced": "OnlyForced" } }, "other": { @@ -227,11 +227,11 @@ "no_active_downloads": "No active downloads", "active_downloads": "Active downloads", "new_app_version_requires_re_download": "New app version requires re-download", - "new_app_version_requires_re_download_description": "The new update reqires content to be downloaded again. Please remove all downloaded content and try again.", + "new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.", "back": "Back", "delete": "Delete", "something_went_wrong": "Something went wrong", - "could_not_get_stream_url_from_jellyfin": "Could not get stream URL from Jellyfin", + "could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin", "eta": "ETA {{eta}}", "methods": "Methods", "toasts": { @@ -249,12 +249,12 @@ "download_failed_for_item": "Download failed for {{item}} - {{error}}", "download_completed_for_item": "Download completed for {{item}}", "queued_item_for_optimization": "Queued {{item}} for optimization", - "failed_to_start_download_for_item": "Failed to start download for {{item}}: {{message}}", + "failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}", "server_responded_with_status_code": "Server responded with status {{statusCode}}", - "no_response_received_from_server": "No response received from server", + "no_response_received_from_server": "No response received from the server", "error_setting_up_the_request": "Error setting up the request", - "failed_to_start_download_for_item_unexpected_error": "Failed to start download for {{item}}: Unexpected error", - "all_files_folders_and_jobs_deleted_successfully": "All files, folders and jobs deleted successfully", + "failed_to_start_download_for_item_unexpected_error": "Failed to start downloading for {{item}}: Unexpected error", + "all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully", "an_error_occured_while_deleting_files_and_jobs": "An error occurred while deleting files and jobs", "go_to_downloads": "Go to downloads" } @@ -338,10 +338,10 @@ }, "player": { "error": "Error", - "failed_to_get_stream_url": "Failed to get stream URL", + "failed_to_get_stream_url": "Failed to get the stream URL", "an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", "client_error": "Client error", - "could_not_create_stream_for_chromecast": "Could not create stream for Chromecast", + "could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast", "message_from_server": "Message from server: {{message}}", "video_has_finished_playing": "Video has finished playing!", "no_video_source": "No video source...", diff --git a/translations/fr.json b/translations/fr.json index 029e5619..2ceb9546 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -89,7 +89,7 @@ "audio_title": "Audio", "set_audio_track": "Piste audio de l'élément précédent", "audio_language": "Langue audio", - "audio_hint": "Chosissez une langue audio par défaut.", + "audio_hint": "Choisissez une langue audio par défaut.", "none": "Aucune", "language": "Langage" },