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 fea3b829..cf902f6f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/LanguageSwitcher.tsx b/components/LanguageSwitcher.tsx new file mode 100644 index 00000000..47c18d4f --- /dev/null +++ b/components/LanguageSwitcher.tsx @@ -0,0 +1,36 @@ +import { Text } from "@/components/common/Text"; +import { useSettings } from "@/utils/atoms/settings"; +import { getLocales } from "expo-localization"; +import { useTranslation } from "react-i18next"; +import { TouchableOpacity, View, ViewProps } from "react-native"; + +interface Props extends ViewProps {} + +export const LanguageSwitcher: React.FC = ({ ...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", }; };