Add automatic language detection and language setting

This commit is contained in:
Zoe Roux 2025-11-08 20:33:18 +01:00
parent 079cc6b4f9
commit 39cfd501ac
No known key found for this signature in database
11 changed files with 120 additions and 123 deletions

View File

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

View File

@ -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=="],

View File

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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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 (
<SettingsContainer title={t("settings.general.label")}>
<Preference
icon={Theme}
label={t("settings.general.theme.label")}
description={t("settings.general.theme.description")}
>
<Select
label={t("settings.general.theme.label")}
value={theme}
onValueChange={(value) => setUserTheme(value)}
values={["auto", "light", "dark"]}
getLabel={(key) => t(`settings.general.theme.${key}`)}
/>
</Preference>
<Preference
icon={Language}
label={t("settings.general.language.label")}
description={t("settings.general.language.description")}
>
<Select
label={t("settings.general.language.label")}
value={i18n.resolvedLanguage! === i18n.systemLanguage ? "system" : i18n.resolvedLanguage!}
onValueChange={(value) => changeLanguage(value)}
values={["system", ...Object.keys(i18n.options.resources!)]}
getLabel={(key) =>
key === "system" ? t("settings.general.language.system") : (getLanguageName(key) ?? key)
}
/>
</Preference>
</SettingsContainer>
);
};
export const About = () => {
const { t } = useTranslation();
return (
<SettingsContainer title={t("settings.about.label")}>
<Link href="https://github.com/zoriya/kyoo/releases/latest/download/kyoo.apk" target="_blank">
<Preference
icon={Android}
label={t("settings.about.android-app.label")}
description={t("settings.about.android-app.description")}
/>
</Link>
<Link href="https://github.com/zoriya/kyoo" target="_blank">
<Preference
icon={Public}
label={t("settings.about.git.label")}
description={t("settings.about.git.description")}
/>
</Link>
</SettingsContainer>
);
};

View File

@ -128,8 +128,7 @@
},
"language": {
"label": "Language",
"description": "Set the language of your application",
"system": "System"
"description": "Set the language of your application"
}
},
"playback": {

View File

@ -0,0 +1,3 @@
import { createLanguageDetector } from "react-native-localization-settings";
export const languageDetector = createLanguageDetector({});

View File

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

View File

@ -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,
},

View File

@ -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<HttpBackendOptions>({
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<HttpBackendOptions>({
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;
}, []);

View File

@ -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 (
<SettingsContainer title={t("settings.general.label")}>
{/* <Preference */}
{/* icon={Theme} */}
{/* label={t("settings.general.theme.label")} */}
{/* description={t("settings.general.theme.description")} */}
{/* > */}
{/* <Select */}
{/* label={t("settings.general.theme.label")} */}
{/* value={theme} */}
{/* onValueChange={(value) => setUserTheme(value)} */}
{/* values={["auto", "light", "dark"]} */}
{/* getLabel={(key) => t(`settings.general.theme.${key}`)} */}
{/* /> */}
{/* </Preference> */}
<Preference
icon={Language}
label={t("settings.general.language.label")}
description={t("settings.general.language.description")}
>
<Select
label={t("settings.general.language.label")}
value={i18n.resolvedLanguage!}
onValueChange={(value) => i18n.changeLanguage(value)}
values={supportedLanguages}
getLabel={(key) => getLanguageName(key) ?? key}
/>
</Preference>
</SettingsContainer>
);
};
export const About = () => {
const { t } = useTranslation();
return (
<SettingsContainer title={t("settings.about.label")}>
<Link
href="https://github.com/zoriya/kyoo/releases/latest/download/kyoo.apk"
target="_blank"
>
<Preference
icon={Android}
label={t("settings.about.android-app.label")}
description={t("settings.about.android-app.description")}
/>
</Link>
<Link href="https://github.com/zoriya/kyoo" target="_blank">
<Preference
icon={Public}
label={t("settings.about.git.label")}
description={t("settings.about.git.description")}
/>
</Link>
</SettingsContainer>
);
};

View File

@ -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 (
<ScrollView contentContainerStyle={{ gap: ts(4), paddingBottom: ts(4) }}>
{/* <GeneralSettings /> */}
<GeneralSettings />
{/* {account && <PlaybackSettings />} */}
{/* {account && <AccountSettings />} */}
{/* {account && <OidcSettings />} */}
{/* <About /> */}
<About />
</ScrollView>
);
};