From 39cfd501ac64d7bff4c1671e800b9aca6a53b45e Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 8 Nov 2025 20:33:18 +0100 Subject: [PATCH] Add automatic language detection and language setting --- front/app.config.ts | 7 ++ front/bun.lock | 6 + front/package.json | 2 + front/packages/ui/src/settings/general.tsx | 103 ------------------ front/public/translations/en.json | 3 +- front/src/providers/translations-detector.tsx | 3 + .../providers/translations-detector.web.tsx | 7 ++ front/src/providers/translations.native.tsx | 3 +- .../src/providers/translations.web.client.tsx | 31 +++--- front/src/ui/settings/general.tsx | 72 ++++++++++++ front/src/ui/settings/index.tsx | 6 +- 11 files changed, 120 insertions(+), 123 deletions(-) delete mode 100644 front/packages/ui/src/settings/general.tsx create mode 100644 front/src/providers/translations-detector.tsx create mode 100644 front/src/providers/translations-detector.web.tsx create mode 100644 front/src/ui/settings/general.tsx diff --git a/front/app.config.ts b/front/app.config.ts index c962f03f..5a4b930c 100644 --- a/front/app.config.ts +++ b/front/app.config.ts @@ -1,4 +1,5 @@ import type { ExpoConfig } from "expo/config"; +import { supportedLanguages } from "~/providers/translations.compile"; const IS_DEV = process.env.APP_VARIANT === "development"; @@ -75,6 +76,12 @@ export const expo: ExpoConfig = { }, }, ], + [ + "react-native-localization-settings", + { + languages: supportedLanguages, + } + ] ], experiments: { typedRoutes: true, diff --git a/front/bun.lock b/front/bun.lock index c2964e20..47c85f0e 100644 --- a/front/bun.lock +++ b/front/bun.lock @@ -28,6 +28,7 @@ "expo-splash-screen": "^31.0.10", "expo-status-bar": "~3.0.8", "expo-updates": "~29.0.11", + "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "jassub": "^1.8.6", "langmap": "^0.0.16", @@ -36,6 +37,7 @@ "react-i18next": "^16.1.0", "react-native": "0.81.5", "react-native-get-random-values": "^2.0.0", + "react-native-localization-settings": "^1.2.0", "react-native-mmkv": "^3.3.3", "react-native-nitro-modules": "^0.30.2", "react-native-reanimated": "~4.1.2", @@ -927,6 +929,8 @@ "i18next": ["i18next@25.6.0", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw=="], + "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="], + "i18next-http-backend": ["i18next-http-backend@3.0.2", "", { "dependencies": { "cross-fetch": "4.0.0" } }, "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -1259,6 +1263,8 @@ "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], + "react-native-localization-settings": ["react-native-localization-settings@1.2.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-pxX/mfokqjwIdb1zINuN6DLL4PeVHTaIGz2Tk833tS94fmpsSuPoYnkCmtXsfvZjxhDOSsRceao/JutJbIlpIQ=="], + "react-native-mmkv": ["react-native-mmkv@3.3.3", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-GMsfOmNzx0p5+CtrCFRVtpOOMYNJXuksBVARSQrCFaZwjUyHJdQzcN900GGaFFNTxw2fs8s5Xje//RDKj9+PZA=="], "react-native-nitro-modules": ["react-native-nitro-modules@0.30.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-+/uVS7FQwOiKYZQERMIvBRv5/X3CVHrFG6Nr/kIhVfVxGeUimHnBz7cgA97lJKIn7AKDRWL+UjLedW8pGOt0dg=="], diff --git a/front/package.json b/front/package.json index fccb6740..b5df9403 100644 --- a/front/package.json +++ b/front/package.json @@ -39,6 +39,7 @@ "expo-splash-screen": "^31.0.10", "expo-status-bar": "~3.0.8", "expo-updates": "~29.0.11", + "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "jassub": "^1.8.6", "langmap": "^0.0.16", @@ -47,6 +48,7 @@ "react-i18next": "^16.1.0", "react-native": "0.81.5", "react-native-get-random-values": "^2.0.0", + "react-native-localization-settings": "^1.2.0", "react-native-mmkv": "^3.3.3", "react-native-nitro-modules": "^0.30.2", "react-native-reanimated": "~4.1.2", diff --git a/front/packages/ui/src/settings/general.tsx b/front/packages/ui/src/settings/general.tsx deleted file mode 100644 index 45ba61ed..00000000 --- a/front/packages/ui/src/settings/general.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Kyoo - A portable and vast media library solution. - * Copyright (c) Kyoo. - * - * See AUTHORS.md and LICENSE file in the project root for full license information. - * - * Kyoo is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * any later version. - * - * Kyoo is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Kyoo. If not, see . - */ - -import { deleteData, setUserTheme, storeData, useUserTheme } from "@kyoo/models"; -import { Link, Select } from "@kyoo/primitives"; -import { useTranslation } from "react-i18next"; -import { Preference, SettingsContainer } from "./base"; - -import Theme from "@material-symbols/svg-400/outlined/dark_mode.svg"; -import Language from "@material-symbols/svg-400/outlined/language.svg"; - -import Android from "@material-symbols/svg-400/rounded/android.svg"; -import Public from "@material-symbols/svg-400/rounded/public.svg"; -import { useLanguageName } from "../utils"; - -export const GeneralSettings = () => { - const { t, i18n } = useTranslation(); - const theme = useUserTheme("auto"); - const getLanguageName = useLanguageName(); - - const changeLanguage = (lang: string) => { - if (lang === "system") { - i18n.changeLanguage(i18n.systemLanguage); - deleteData("language"); - return; - } - storeData("language", lang); - i18n.changeLanguage(lang); - }; - - return ( - - - changeLanguage(value)} - values={["system", ...Object.keys(i18n.options.resources!)]} - getLabel={(key) => - key === "system" ? t("settings.general.language.system") : (getLanguageName(key) ?? key) - } - /> - - - ); -}; - -export const About = () => { - const { t } = useTranslation(); - - return ( - - - - - - - - - ); -}; diff --git a/front/public/translations/en.json b/front/public/translations/en.json index e2a5fbac..04dc6a41 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -128,8 +128,7 @@ }, "language": { "label": "Language", - "description": "Set the language of your application", - "system": "System" + "description": "Set the language of your application" } }, "playback": { diff --git a/front/src/providers/translations-detector.tsx b/front/src/providers/translations-detector.tsx new file mode 100644 index 00000000..19a80c04 --- /dev/null +++ b/front/src/providers/translations-detector.tsx @@ -0,0 +1,3 @@ +import { createLanguageDetector } from "react-native-localization-settings"; + +export const languageDetector = createLanguageDetector({}); diff --git a/front/src/providers/translations-detector.web.tsx b/front/src/providers/translations-detector.web.tsx new file mode 100644 index 00000000..c003a4c3 --- /dev/null +++ b/front/src/providers/translations-detector.web.tsx @@ -0,0 +1,7 @@ +import LanguageDetector from "i18next-browser-languagedetector"; + +export const languageDetector = new LanguageDetector(null, { + order: ["querystring", "cookie", "navigator", "path", "subdomain"], + caches: ["cookie"], + cookieMinutes: 525600, // 1 years +}); diff --git a/front/src/providers/translations.native.tsx b/front/src/providers/translations.native.tsx index 1c118e15..14bf84f5 100644 --- a/front/src/providers/translations.native.tsx +++ b/front/src/providers/translations.native.tsx @@ -3,11 +3,12 @@ import { type ReactNode, useMemo } from "react"; import { I18nextProvider } from "react-i18next"; import { setServerData } from "~/utils"; import { resources, supportedLanguages } from "./translations.compile"; +import { languageDetector } from "./translations-detector"; export const TranslationsProvider = ({ children }: { children: ReactNode }) => { const val = useMemo(() => { const i18n = i18next.createInstance(); - i18n.init({ + i18n.use(languageDetector).init({ interpolation: { escapeValue: false, }, diff --git a/front/src/providers/translations.web.client.tsx b/front/src/providers/translations.web.client.tsx index aaf129c9..7d37397b 100644 --- a/front/src/providers/translations.web.client.tsx +++ b/front/src/providers/translations.web.client.tsx @@ -4,24 +4,27 @@ import { type ReactNode, useMemo } from "react"; import { I18nextProvider } from "react-i18next"; import { getServerData } from "~/utils"; import { supportedLanguages } from "./translations.compile"; +import { languageDetector } from "./translations-detector"; export const TranslationsProvider = ({ children }: { children: ReactNode }) => { const val = useMemo(() => { const i18n = i18next.createInstance(); - // TODO: use https://github.com/i18next/i18next-browser-languageDetector - i18n.use(HttpApi).init({ - interpolation: { - escapeValue: false, - }, - returnEmptyString: false, - fallbackLng: "en", - load: "currentOnly", - supportedLngs: supportedLanguages, - // we don't need to cache resources since we always get a fresh one from ssr - backend: { - loadPath: "/translations/{{lng}}.json", - }, - }); + i18n + .use(HttpApi) + .use(languageDetector) + .init({ + interpolation: { + escapeValue: false, + }, + returnEmptyString: false, + fallbackLng: "en", + load: "currentOnly", + supportedLngs: supportedLanguages, + // we don't need to cache resources since we always get a fresh one from ssr + backend: { + loadPath: "/translations/{{lng}}.json", + }, + }); i18n.services.resourceStore.data = getServerData("translations"); return i18n; }, []); diff --git a/front/src/ui/settings/general.tsx b/front/src/ui/settings/general.tsx new file mode 100644 index 00000000..f874073b --- /dev/null +++ b/front/src/ui/settings/general.tsx @@ -0,0 +1,72 @@ +import Theme from "@material-symbols/svg-400/outlined/dark_mode.svg"; +import Language from "@material-symbols/svg-400/outlined/language.svg"; +import Android from "@material-symbols/svg-400/rounded/android.svg"; +import Public from "@material-symbols/svg-400/rounded/public.svg"; +import { useTranslation } from "react-i18next"; +import { Link, Select } from "~/primitives"; +import { supportedLanguages } from "~/providers/translations.compile"; +import { useLanguageName } from "~/track-utils"; +import { Preference, SettingsContainer } from "./base"; + +export const GeneralSettings = () => { + const { t, i18n } = useTranslation(); + // const theme = useUserTheme("auto"); + const getLanguageName = useLanguageName(); + + return ( + + {/* */} + {/* i18n.changeLanguage(value)} + values={supportedLanguages} + getLabel={(key) => getLanguageName(key) ?? key} + /> + + + ); +}; + +export const About = () => { + const { t } = useTranslation(); + + return ( + + + + + + + + + ); +}; diff --git a/front/src/ui/settings/index.tsx b/front/src/ui/settings/index.tsx index 3d8b4e11..3955ae3a 100644 --- a/front/src/ui/settings/index.tsx +++ b/front/src/ui/settings/index.tsx @@ -2,7 +2,7 @@ import { ScrollView } from "react-native"; import { ts } from "~/primitives"; import { useAccount } from "~/providers/account-context"; // import { AccountSettings } from "./account"; -// import { About, GeneralSettings } from "./general"; +import { About, GeneralSettings } from "./general"; // import { OidcSettings } from "./oidc"; // import { PlaybackSettings } from "./playback"; @@ -10,11 +10,11 @@ export const SettingsPage = () => { const account = useAccount(); return ( - {/* */} + {/* {account && } */} {/* {account && } */} {/* {account && } */} - {/* */} + ); };