diff --git a/app.json b/app.json index fc2c4505..1052c0c4 100644 --- a/app.json +++ b/app.json @@ -105,6 +105,7 @@ "motionPermission": "Allow Streamyfin to access your device motion for landscape video watching." } ], + "expo-localization", "expo-asset", [ "react-native-edge-to-edge", 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 ( ([]); + 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)/(favorites)/_layout.tsx b/app/(auth)/(tabs)/(favorites)/_layout.tsx index d48dc614..b408eab6 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 ( 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()]); @@ -98,9 +101,9 @@ export default function page() { {settings?.downloadMethod === DownloadMethod.Remux && ( - Queue + {t("home.downloads.queue")} - Queue and active downloads will be lost on app restart + {t("home.downloads.queue_hint")} {queue.map((q, index) => ( @@ -133,7 +136,7 @@ export default function page() { {queue.length === 0 && ( - No items in queue + {t("home.downloads.no_items_in_queue")} )} )} @@ -144,7 +147,7 @@ export default function page() { {movies.length > 0 && ( - Movies + {t("home.downloads.movies")} {movies?.length} @@ -163,7 +166,7 @@ export default function page() { {groupedBySeries.length > 0 && ( - TV-Series + {t("home.downloads.tvseries")} {groupedBySeries?.length} @@ -189,7 +192,7 @@ export default function page() { )} {downloadedFiles?.length === 0 && ( - No downloaded items + {t("home.downloads.no_downloaded_items")} )} @@ -214,13 +217,13 @@ export default function page() { @@ -233,15 +236,15 @@ function migration_20241124() { const router = useRouter(); const { deleteAllFiles } = useDownload(); Alert.alert( - "New app version requires re-download", - "The new update reqires content to be downloaded again. Please remove all downloaded content and try again.", + t("home.downloads.new_app_version_requires_re_download"), + t("home.downloads.new_app_version_requires_re_download_description"), [ { - text: "Back", + text: t("home.downloads.back"), onPress: () => router.back(), }, { - text: "Delete", + text: t("home.downloads.delete"), style: "destructive", onPress: async () => await deleteAllFiles(), }, diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index ae8594f8..fdbe0ea2 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -27,6 +27,7 @@ import { QueryFunction, useQuery } from "@tanstack/react-query"; import { useNavigation, useRouter } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { ActivityIndicator, RefreshControl, @@ -55,6 +56,8 @@ type Section = ScrollingCollectionListSection | MediaListSection; export default function index() { const router = useRouter(); + const { t } = useTranslation(); + const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); @@ -204,7 +207,7 @@ export default function index() { const latestMediaViews = collections.map((c) => { const includeItemTypes: BaseItemKind[] = c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; - const title = "Recently Added in " + c.Name; + const title = t("home.recently_added_in", {libraryName: c.Name}); const queryKey = [ "home", "recentlyAddedIn" + c.CollectionType, @@ -221,7 +224,7 @@ export default function index() { const ss: Section[] = [ { - title: "Continue Watching", + title: t("home.continue_watching"), queryKey: ["home", "resumeItems"], queryFn: async () => ( @@ -235,7 +238,7 @@ export default function index() { orientation: "horizontal", }, { - title: "Next Up", + title: t("home.next_up"), queryKey: ["home", "nextUp-all"], queryFn: async () => ( @@ -262,7 +265,7 @@ export default function index() { // } as Section) // ) || []), { - title: "Suggested Movies", + title: t("home.suggested_movies"), queryKey: ["home", "suggestedMovies", user?.Id], queryFn: async () => ( @@ -277,7 +280,7 @@ export default function index() { orientation: "vertical", }, { - title: "Suggested Episodes", + title: t("home.suggested_episodes"), queryKey: ["home", "suggestedEpisodes", user?.Id], queryFn: async () => { try { @@ -347,9 +350,9 @@ export default function index() { if (isConnected === false) { return ( - No Internet + {t("home.no_internet")} - No worries, you can still watch{"\n"}downloaded content. + {t("home.no_internet_message")} { @@ -131,7 +127,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.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index b96802a9..80a515dc 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -10,11 +10,13 @@ import { PluginSettings } from "@/components/settings/PluginSettings"; import { QuickConnect } from "@/components/settings/QuickConnect"; import { StorageSettings } from "@/components/settings/StorageSettings"; import { SubtitleToggles } from "@/components/settings/SubtitleToggles"; +import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector"; import { UserInfo } from "@/components/settings/UserInfo"; import { useJellyfin } from "@/providers/JellyfinProvider"; import { clearLogs } from "@/utils/log"; import { useHaptic } from "@/hooks/useHaptic"; import { useNavigation, useRouter } from "expo-router"; +import { t } from "i18next"; import React, { useEffect } from "react"; import { ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -40,7 +42,7 @@ export default function settings() { logout(); }} > - Log out + {t("home.settings.log_out_button")} ), }); @@ -68,33 +70,35 @@ 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")} /> - + router.push("/settings/logs/page")} showArrow - title={"Logs"} + title={t("home.settings.logs.logs_title")} /> 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/app/(auth)/(tabs)/(home)/settings/logs/page.tsx b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx index 2e023c7d..1c59ba15 100644 --- a/app/(auth)/(tabs)/(home)/settings/logs/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx @@ -1,9 +1,11 @@ import { Text } from "@/components/common/Text"; import { useLog } from "@/utils/log"; import { ScrollView, View } from "react-native"; +import { useTranslation } from "react-i18next"; export default function page() { const { logs } = useLog(); + const { t } = useTranslation(); return ( @@ -25,7 +27,7 @@ export default function page() { ))} {logs?.length === 0 && ( - No logs available + {t("home.settings.logs.no_logs_available")} )} diff --git a/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx b/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx index dab489cb..b67f6ea0 100644 --- a/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx @@ -4,6 +4,8 @@ import { ListItem } from "@/components/list/ListItem"; import { useSettings } from "@/utils/atoms/settings"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigation } from "expo-router"; +import { useTranslation } from "react-i18next"; + import React, {useEffect, useMemo, useState} from "react"; import { Linking, @@ -18,6 +20,8 @@ import DisabledSetting from "@/components/settings/DisabledSetting"; export default function page() { const navigation = useNavigation(); + const { t } = useTranslation(); + const [settings, updateSettings, pluginSettings] = useSettings(); const queryClient = useQueryClient(); @@ -27,7 +31,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 = () => { @@ -43,7 +47,7 @@ export default function page() { navigation.setOptions({ headerRight: () => ( onSave(value)}> - Save + {t("home.settings.plugins.marlin_search.save_button")} ), }); @@ -63,7 +67,7 @@ export default function page() { showText={!pluginSettings?.marlinServerUrl?.locked} > { updateSettings({ searchEngine: "Jellyfin" }); queryClient.invalidateQueries({ queryKey: ["search"] }); @@ -88,11 +92,11 @@ export default function page() { - URL + {t("home.settings.plugins.marlin_search.url")} - Enter the URL for the Marlin server. The URL should include http or - https and optionally the port.{" "} + {t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "} - Read more about Marlin. + {t("home.settings.plugins.marlin_search.read_more_about_marlin")} diff --git a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx index 11930607..988651f0 100644 --- a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx @@ -10,11 +10,14 @@ import { useAtom } from "jotai"; import { useEffect, useState } from "react"; import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { toast } from "sonner-native"; +import { useTranslation } from "react-i18next"; import DisabledSetting from "@/components/settings/DisabledSetting"; export default function page() { const navigation = useNavigation(); + const { t } = useTranslation(); + const [api] = useAtom(apiAtom); const [settings, updateSettings, pluginSettings] = 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")); }, }); @@ -59,13 +62,13 @@ export default function page() { useEffect(() => { if (!pluginSettings?.optimizedVersionsServerUrl?.locked) { 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")} { 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.filters.genres")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase()) @@ -271,7 +274,7 @@ const page: React.FC = () => { }} set={setSelectedYears} values={selectedYears} - title="Years" + title={t("library.filters.years")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.includes(search)} /> @@ -296,7 +299,7 @@ const page: React.FC = () => { }} set={setSelectedTags} values={selectedTags} - title="Tags" + title={t("library.filters.tags")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase()) @@ -314,7 +317,7 @@ const page: React.FC = () => { queryFn={async () => sortOptions.map((s) => s.key)} set={setSortBy} values={sortBy} - title="Sort By" + title={t("library.filters.sort_by")} renderItemLabel={(item) => sortOptions.find((i) => i.key === item)?.value || "" } @@ -334,7 +337,7 @@ const page: React.FC = () => { queryFn={async () => sortOrderOptions.map((s) => s.key)} set={setSortOrder} values={sortOrder} - title="Sort Order" + title={t("library.filters.sort_order")} renderItemLabel={(item) => sortOrderOptions.find((i) => i.key === item)?.value || "" } @@ -374,7 +377,7 @@ const page: React.FC = () => { - 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 3605a665..3cf03a9a 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx @@ -17,6 +17,7 @@ import { import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search"; import { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; +import { useTranslation } from "react-i18next"; import { Ionicons } from "@expo/vector-icons"; import { BottomSheetBackdrop, @@ -39,6 +40,8 @@ import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/request const Page: React.FC = () => { const insets = useSafeAreaInsets(); const params = useLocalSearchParams(); + const { t } = useTranslation(); + const { mediaTitle, releaseYear, posterSrc, ...result } = params as unknown as { mediaTitle: string; @@ -214,7 +217,7 @@ const Page: React.FC = () => { ) : canRequest ? ( ) : ( )} @@ -281,7 +284,7 @@ const Page: React.FC = () => { - Whats wrong? + {t("jellyseerr.whats_wrong")} @@ -290,13 +293,13 @@ const Page: React.FC = () => { - Issue Type + {t("jellyseerr.issue_type")} {issueType ? IssueTypeName[issueType] - : "Select an issue"} + : t("jellyseerr.select_an_issue")} @@ -310,7 +313,7 @@ const Page: React.FC = () => { collisionPadding={0} sideOffset={0} > - Types + {t("jellyseerr.types")} {Object.entries(IssueTypeName) .reverse() .map(([key, value], idx) => ( @@ -335,7 +338,7 @@ const Page: React.FC = () => { maxLength={254} style={{ color: "white" }} clearButtonMode="always" - placeholder="(optional) Describe the issue..." + placeholder={t("jellyseerr.describe_the_issue")} placeholderTextColor="#9CA3AF" // Issue with multiline + Textinput inside a portal // https://github.com/callstack/react-native-paper/issues/1668 @@ -345,7 +348,7 @@ const Page: React.FC = () => { diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx index 0ae41049..f152563a 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx @@ -13,9 +13,12 @@ import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; +import { useTranslation } from "react-i18next"; export default function page() { const local = useLocalSearchParams(); + const { t } = useTranslation(); + const { jellyseerrApi, jellyseerrUser } = useJellyseerr(); const { personId } = local as { personId: string }; @@ -58,7 +61,7 @@ export default function page() { 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)/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)/(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/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 414f6e90..15c9aa52 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -41,6 +41,7 @@ import { } from "@jellyfin/sdk/lib/utils/api"; import { FlashList } from "@shopify/flash-list"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useTranslation } from "react-i18next"; const Page = () => { const searchParams = useLocalSearchParams(); @@ -62,6 +63,8 @@ const Page = () => { const { orientation } = useOrientation(); + const { t } = useTranslation(); + useEffect(() => { const sop = getSortOrderPreference(libraryId, sortOrderPreference); if (sop) { @@ -298,7 +301,7 @@ const Page = () => { }} set={setSelectedGenres} values={selectedGenres} - title="Genres" + title={t("library.filters.genres")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase()) @@ -325,7 +328,7 @@ const Page = () => { }} set={setSelectedYears} values={selectedYears} - title="Years" + title={t("library.filters.years")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.includes(search)} /> @@ -350,7 +353,7 @@ const Page = () => { }} set={setSelectedTags} values={selectedTags} - title="Tags" + title={t("library.filters.tags")} renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase()) @@ -368,7 +371,7 @@ const Page = () => { queryFn={async () => sortOptions.map((s) => s.key)} set={setSortBy} values={sortBy} - title="Sort By" + title={t("library.filters.sort_by")} renderItemLabel={(item) => sortOptions.find((i) => i.key === item)?.value || "" } @@ -388,7 +391,7 @@ const Page = () => { queryFn={async () => sortOrderOptions.map((s) => s.key)} set={setSortOrder} values={sortOrder} - title="Sort Order" + title={t("library.filters.sort_order")} renderItemLabel={(item) => sortOrderOptions.find((i) => i.key === item)?.value || "" } @@ -434,7 +437,7 @@ const Page = () => { if (flatData.length === 0) return ( - No items found + {t("library.no_items_found")} ); @@ -443,7 +446,7 @@ const Page = () => { key={orientation} ListEmptyComponent={ - No results + {t("library.no_results")} } contentInsetAdjustmentBehavior="automatic" diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx index 439e41df..5cce9784 100644 --- a/app/(auth)/(tabs)/(libraries)/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx @@ -4,10 +4,13 @@ import { Ionicons } from "@expo/vector-icons"; import { Stack } from "expo-router"; import { Platform } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; +import { useTranslation } from "react-i18next"; export default function IndexLayout() { const [settings, updateSettings, pluginSettings] = useSettings(); + const { t } = useTranslation(); + if (!settings?.libraryOptions) return null; return ( @@ -17,7 +20,7 @@ export default function IndexLayout() { options={{ headerShown: true, headerLargeTitle: true, - headerTitle: "Library", + headerTitle: t("tabs.library"), headerBlurEffect: "prominent", headerLargeStyle: { backgroundColor: "black", @@ -43,11 +46,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")} @@ -158,7 +161,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)/(libraries)/index.tsx b/app/(auth)/(tabs)/(libraries)/index.tsx index 08adacc5..ba11cc45 100644 --- a/app/(auth)/(tabs)/(libraries)/index.tsx +++ b/app/(auth)/(tabs)/(libraries)/index.tsx @@ -13,6 +13,7 @@ import { useAtom } from "jotai"; import { useEffect, useMemo } from "react"; import { StyleSheet, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useTranslation } from "react-i18next"; export default function index() { const [api] = useAtom(apiAtom); @@ -20,6 +21,8 @@ export default function index() { const queryClient = useQueryClient(); const [settings] = useSettings(); + const { t } = useTranslation(); + const { data, isLoading: isLoading } = useQuery({ queryKey: ["user-views", user?.Id], queryFn: async () => { @@ -70,7 +73,7 @@ export default function index() { if (!libraries) return ( - No libraries found + {t("library.no_libraries_found")} ); diff --git a/app/(auth)/(tabs)/(search)/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx index 1f6a3c8b..b031908e 100644 --- a/app/(auth)/(tabs)/(search)/_layout.tsx +++ b/app/(auth)/(tabs)/(search)/_layout.tsx @@ -4,8 +4,10 @@ import { } from "@/components/stacks/NestedTabPageStack"; import { Stack } from "expo-router"; import { Platform } from "react-native"; +import { useTranslation } from "react-i18next"; export default function SearchLayout() { + const { t } = useTranslation(); return ( }; const [searchType, setSearchType] = useState("Library"); @@ -122,7 +125,7 @@ export default function search() { if (Platform.OS === "ios") navigation.setOptions({ headerSearchBarOptions: { - placeholder: "Search...", + placeholder: t("search.search"), onChangeText: (e: any) => { router.setParams({ q: "" }); setSearch(e.nativeEvent.text); @@ -214,7 +217,7 @@ export default function search() { autoCorrect={false} returnKeyType="done" keyboardType="web-search" - placeholder="Search here..." + placeholder={t("search.search_here")} value={search} onChangeText={(text) => setSearch(text)} /> @@ -224,7 +227,7 @@ export default function search() { setSearchType("Library")}> setSearchType("Discover")}> 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) => ( 0 ? ( - No results found for + {t("search.no_results_found_for")} "{debouncedSearch}" diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index 28b2ffa2..ade003ff 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useRef } from "react"; import { Platform } from "react-native"; +import { useTranslation } from "react-i18next"; import { useFocusEffect, useRouter, withLayoutContext } from "expo-router"; @@ -30,6 +31,7 @@ export const NativeTabs = withLayoutContext< export default function TabLayout() { const [settings] = useSettings(); + const { t } = useTranslation(); const router = useRouter(); useFocusEffect( @@ -61,7 +63,7 @@ export default function TabLayout() { @@ -75,7 +77,7 @@ export default function TabLayout() { @@ -89,7 +91,7 @@ export default function TabLayout() { @@ -105,7 +107,7 @@ export default function TabLayout() { @@ -119,7 +121,7 @@ export default function TabLayout() { (null); const user = useAtomValue(userAtom); const api = useAtomValue(apiAtom); + const { t } = useTranslation(); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [showControls, _setShowControls] = useState(true); @@ -161,7 +163,7 @@ export default function page() { const { mediaSource, sessionId, url } = res; if (!sessionId || !mediaSource || !url) { - Alert.alert("Error", "Failed to get stream url"); + Alert.alert(t("player.error"), t("player.failed_to_get_stream_url")); return null; } @@ -426,7 +428,7 @@ export default function page() { if (isErrorItem || isErrorStreamUrl) return ( - Error + {t("player.error")} ); @@ -465,8 +467,8 @@ export default function page() { onVideoError={(e) => { console.error("Video Error:", e.nativeEvent); Alert.alert( - "Error", - "An error occurred while playing the video. Check logs in settings." + t("player.error"), + t("player.an_error_occured_while_playing_the_video") ); writeToLog("ERROR", "Video Error", e.nativeEvent); }} diff --git a/app/(auth)/player/transcoding-player.tsx b/app/(auth)/player/transcoding-player.tsx index 8c9a4b84..38a2b2e5 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(); @@ -374,7 +376,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/app/_layout.tsx b/app/_layout.tsx index 23512523..2092d722 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -40,6 +40,9 @@ import { useEffect, useRef } from "react"; import { Appearance, AppState, TouchableOpacity } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { I18nextProvider, useTranslation } from "react-i18next"; +import i18n from "@/i18n"; +import { getLocales } from "expo-localization"; import "react-native-reanimated"; import { Toaster } from "sonner-native"; @@ -228,7 +231,9 @@ export default function RootLayout() { return ( - + + + ); } @@ -252,6 +257,8 @@ function Layout() { useKeepAwake(); useNotificationObserver(); + const { i18n } = useTranslation(); + useEffect(() => { checkAndRequestPermissions(); }, []); @@ -265,6 +272,12 @@ function Layout() { ); }, [settings]); + useEffect(() => { + i18n.changeLanguage( + settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en" + ); + }, [settings?.preferedLanguage, i18n]); + const appState = useRef(AppState.currentState); useEffect(() => { diff --git a/app/login.tsx b/app/login.tsx index 09eed367..b55f4b1f 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -21,12 +21,11 @@ import { } from "react-native"; import { z } from "zod"; - +import { t } from 'i18next'; const CredentialsSchema = z.object({ - username: z.string().min(1, "Username is required"), -}); + username: z.string().min(1, t("login.username_required")),}); -const Login: React.FC = () => { + const Login: React.FC = () => { const { setServer, login, removeServer, initiateQuickConnect } = useJellyfin(); const [api] = useAtom(apiAtom); @@ -80,7 +79,7 @@ const Login: React.FC = () => { className="flex flex-row items-center" > - Change server + {t("login.change_server")} ) : null, }); @@ -97,9 +96,9 @@ const Login: React.FC = () => { } } catch (error) { if (error instanceof Error) { - Alert.alert("Connection failed", error.message); + Alert.alert(t("login.connection_failed"), error.message); } else { - Alert.alert("Connection failed", "An unexpected error occurred"); + Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured")); } } finally { setLoading(false); @@ -168,8 +167,8 @@ const Login: React.FC = () => { 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; } @@ -181,14 +180,14 @@ const Login: React.FC = () => { try { const code = await initiateQuickConnect(); if (code) { - Alert.alert("Quick Connect", `Enter code ${code} to login`, [ + Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [ { - text: "Got It", + text: t("login.got_it"), }, ]); } } catch (error) { - Alert.alert("Error", "Failed to initiate Quick Connect"); + Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect")); } }; @@ -202,22 +201,21 @@ const Login: React.FC = () => { - - Log in - <> - {serverName ? ( - <> - {" to "} - {serverName} - - ) : null} - - + + <> + {serverName ? ( + <> + {t("login.login_to_title") + " "} + {serverName} + + ) : t("login.login_title")} + + {api.basePath} setCredentials({ ...credentials, username: text }) } @@ -233,7 +231,7 @@ const Login: React.FC = () => { /> setCredentials({ ...credentials, password: text }) } @@ -252,7 +250,7 @@ const Login: React.FC = () => { loading={loading} className="flex-1 mr-2" > - Log in + {t("login.login_button")} { /> Streamyfin - Enter the URL to your Jellyfin server + {t("server.enter_url_to_jellyfin_server")} { textContentType="URL" maxLength={500} /> - { diff --git a/bun.lockb b/bun.lockb index de11fa7d..f242a822 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index 75fd659c..b4ab7b9a 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("item_card.audio")} {selectedAudioSteam?.DisplayTitle} diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index d08a939a..be00cc9e 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; @@ -63,6 +64,8 @@ export const BitrateSelector: React.FC = ({ ); }, []); + const { t } = useTranslation(); + return ( = ({ - Quality + {t("item_card.quality")} {BITRATES.find((b) => b.value === selected?.value)?.key} diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 35ed063c..dcb14128 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 { t } from "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 { 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; } @@ -330,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})} @@ -368,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")} @@ -391,7 +393,9 @@ export const DownloadSingleItem: React.FC<{ return ( ( diff --git a/components/ItemTechnicalDetails.tsx b/components/ItemTechnicalDetails.tsx index 0c472192..6b5852a4 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("item_card.video")} bottomSheetModalRef.current?.present()}> - More details + {t("item_card.more_details")} = ({ source, ...props }) => { - Video + {t("item_card.video")} - Audio + {t("item_card.audio")} = ({ source, ...props }) => { - Subtitles + {t("item_card.subtitles")} 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) => ( { item: BaseItemDto; @@ -27,6 +28,8 @@ export const MediaSourceSelector: React.FC = ({ [item, selected] ); + const { t } = useTranslation(); + const commonPrefix = useMemo(() => { const mediaSources = item.MediaSources || []; if (!mediaSources.length) return ""; @@ -58,7 +61,7 @@ export const MediaSourceSelector: React.FC = ({ - Video + {t("item_card.video")} {selectedName} diff --git a/components/MoreMoviesWithActor.tsx b/components/MoreMoviesWithActor.tsx index 9a2a044f..9d4bc500 100644 --- a/components/MoreMoviesWithActor.tsx +++ b/components/MoreMoviesWithActor.tsx @@ -11,6 +11,7 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useQuery } from "@tanstack/react-query"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; +import { useTranslation } from "react-i18next"; interface Props extends ViewProps { actorId: string; @@ -24,6 +25,7 @@ export const MoreMoviesWithActor: React.FC = ({ }) => { 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("item_card.more_with", {name: actor?.Name})} = ({ ...props }) => { const [limit, setLimit] = useState(characterLimit); + const { t } = useTranslation(); if (!text) return null; return ( - Overview + {t("item_card.overview")} setLimit((prev) => @@ -31,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/PlayButton.tsx b/components/PlayButton.tsx index e432f2a8..611999d4 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -32,6 +32,7 @@ import Animated, { import { Button } from "./Button"; import { SelectedOptions } from "./ItemContent"; import { chromecastProfile } from "@/utils/profiles/chromecast"; +import { useTranslation } from "react-i18next"; import { useHaptic } from "@/hooks/useHaptic"; interface Props extends React.ComponentProps { @@ -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); @@ -132,8 +134,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/PreviousServersList.tsx b/components/PreviousServersList.tsx index 3771961e..437c756d 100644 --- a/components/PreviousServersList.tsx +++ b/components/PreviousServersList.tsx @@ -3,6 +3,7 @@ import { View } from "react-native"; import { useMMKVString } from "react-native-mmkv"; import { ListGroup } from "./list/ListGroup"; import { ListItem } from "./list/ListItem"; +import { useTranslation } from "react-i18next"; interface Server { address: string; @@ -22,11 +23,13 @@ export const PreviousServersList: React.FC = ({ return JSON.parse(_previousServers || "[]") as Server[]; }, [_previousServers]); + const { t } = useTranslation(); + if (!previousServers.length) return null; return ( - + {previousServers.map((s) => ( = ({ onPress={() => { setPreviousServers("[]"); }} - title={"Clear"} + title={t("server.clear_button")} textColor="red" /> diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx index 46815b6d..45914d9f 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("item_card.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("item_card.subtitles")} {selectedSubtitleSteam ? tc(selectedSubtitleSteam?.DisplayTitle, 7) - : "None"} + : t("item_card.none")} 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 e42027ab..47c79f5d 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -20,6 +20,7 @@ import { Button } from "../Button"; import { Image } from "expo-image"; import { useMemo } from "react"; import { storage } from "@/utils/mmkv"; +import { t } from "i18next"; interface Props extends ViewProps {} @@ -28,14 +29,14 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { 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) => ( @@ -80,11 +81,11 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { } }, onSuccess: () => { - toast.success("Download canceled"); + toast.success(t("home.downloads.toasts.download_cancelled")); }, onError: (e) => { console.error(e); - toast.error("Could not cancel download"); + toast.error(t("home.downloads.toasts.could_not_cancel_download")); }, }); @@ -151,7 +152,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 fe6d9f6a..cc5d4300 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); @@ -153,10 +155,10 @@ export const FilterSheet = ({ > {title} - {_data?.length} items + {t("search.items", {count: _data?.length})} {showSearch && ( { diff --git a/components/home/Favorites.tsx b/components/home/Favorites.tsx index 95920ea5..c4ab373e 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); @@ -60,38 +61,38 @@ export const Favorites = () => { diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 48c4c234..6b4ef40c 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -11,6 +11,7 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { ItemCardText } from "../ItemCardText"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; import SeriesPoster from "../posters/SeriesPoster"; +import { useTranslation } from "react-i18next"; interface Props extends ViewProps { title?: string | null; @@ -43,6 +44,8 @@ export const ScrollingCollectionList: React.FC = ({ 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/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/JellyseerrIndexPage.tsx b/components/jellyseerr/JellyseerrIndexPage.tsx index 4b9a3488..cd093deb 100644 --- a/components/jellyseerr/JellyseerrIndexPage.tsx +++ b/components/jellyseerr/JellyseerrIndexPage.tsx @@ -20,6 +20,7 @@ import JellyseerrPoster from "../posters/JellyseerrPoster"; import { LoadingSkeleton } from "../search/LoadingSkeleton"; import { SearchItemWrapper } from "../search/SearchItemWrapper"; import PersonPoster from "./PersonPoster"; +import { useTranslation } from "react-i18next"; interface Props extends ViewProps { searchQuery: string; @@ -28,6 +29,7 @@ interface Props extends ViewProps { export const JellyserrIndexPage: React.FC = ({ searchQuery }) => { const { jellyseerrApi } = useJellyseerr(); const opacity = useSharedValue(1); + const { t } = useTranslation(); const { data: jellyseerrDiscoverSettings, @@ -117,7 +119,7 @@ export const JellyserrIndexPage: React.FC = ({ searchQuery }) => { !l2 && ( - No results found for + {t("search.no_results_found_for")} "{searchQuery}" @@ -127,21 +129,21 @@ export const JellyserrIndexPage: React.FC = ({ searchQuery }) => { ( )} /> ( )} /> ( (); 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/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())} = ({ 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/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx index 01cd1e84..e774b561 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("item_card.cast_and_crew")} i.Id.toString()} diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx index 52851533..16536a6d 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("item_card.series")} - Alert.alert("Confirm", "Are you sure you want to request all seasons?", [ + Alert.alert(t("jellyseerr.confirm"), t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"), [ { - text: "Cancel", + text: t("jellyseerr.cancel"), style: "cancel", }, { - text: "Yes", + text: t("jellyseerr.yes"), onPress: requestAll, }, ]), @@ -207,7 +208,7 @@ const JellyseerrSeasons: React.FC<{ return ( - Seasons + {t("item_card.seasons")} {!allSeasonsAvailable && ( @@ -227,7 +228,7 @@ const JellyseerrSeasons: React.FC<{ )} ListHeaderComponent={() => ( - Seasons + {t("item_card.seasons")} {!allSeasonsAvailable && ( @@ -255,8 +256,8 @@ const JellyseerrSeasons: React.FC<{ {[0].map(() => { diff --git a/components/series/NextUp.tsx b/components/series/NextUp.tsx index 95834b9d..c76a61c6 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("item_card.next_up")} + {t("item_card.no_items_to_display")} ); return ( - Next up + {t("item_card.next_up")} = ({ - Season {seasonIndex} + {t("item_card.season")} {seasonIndex} @@ -104,7 +105,7 @@ export const SeasonDropdown: React.FC = ({ collisionPadding={8} sideOffset={8} > - Seasons + {t("item_card.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 ?? ""], @@ -145,7 +146,7 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { /> {episodes?.length || 0 > 0 ? ( ( @@ -210,7 +211,7 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { {(episodes?.length || 0) === 0 ? ( - No episodes for this season + {t("item_card.no_episodes_for_this_season")} ) : null} diff --git a/components/settings/AppLanguageSelector.tsx b/components/settings/AppLanguageSelector.tsx new file mode 100644 index 00000000..5fdddba8 --- /dev/null +++ b/components/settings/AppLanguageSelector.tsx @@ -0,0 +1,76 @@ +import * as DropdownMenu from "zeego/dropdown-menu"; +import { TouchableOpacity, View, ViewProps } from "react-native"; +import { Text } from "../common/Text"; +import { useSettings } from "@/utils/atoms/settings"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; +import { useTranslation } from "react-i18next"; +import { APP_LANGUAGES } from "@/i18n"; + +interface Props extends ViewProps {} + +export const AppLanguageSelector: React.FC = ({ ...props }) => { + const [settings, updateSettings] = useSettings(); + const { t } = useTranslation(); + + if (!settings) return null; + + return ( + + + + + + + + {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/AudioToggles.tsx b/components/settings/AudioToggles.tsx index 6afaedf4..44c1a2a8 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"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; import { Ionicons } from "@expo/vector-icons"; @@ -15,21 +16,22 @@ export const AudioToggles: React.FC = ({ ...props }) => { const [_, __, pluginSettings] = useSettings(); const { settings, updateSettings } = media; const cultures = media.cultures; + const { t } = useTranslation(); if (!settings) return null; return ( - Choose a default audio language. + {t("home.settings.audio.audio_hint")} } > = ({ ...props }) => { } /> - + - {settings?.defaultAudioLanguage?.DisplayName || "None"} + {settings?.defaultAudioLanguage?.DisplayName || t("home.settings.audio.none")} = ({ ...props }) => { collisionPadding={8} sideOffset={8} > - Languages + {t("home.settings.audio.language")} { @@ -72,7 +74,7 @@ export const AudioToggles: React.FC = ({ ...props }) => { }); }} > - None + {t("home.settings.audio.none")} {cultures?.map((l) => ( { @@ -17,6 +18,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => { const { setProcesses } = useDownload(); const router = useRouter(); const queryClient = useQueryClient(); + const { t } = useTranslation(); const allDisabled = useMemo( () => @@ -30,9 +32,9 @@ export const DownloadSettings: React.FC = ({ ...props }) => { return ( - + @@ -40,8 +42,8 @@ export const DownloadSettings: React.FC = ({ ...props }) => { {settings.downloadMethod === DownloadMethod.Remux - ? "Default" - : "Optimized"} + ? t("home.settings.downloads.default") + : t("home.settings.downloads.optimized")} { collisionPadding={8} sideOffset={8} > - Methods + {t("home.settings.downloads.methods")} { @@ -67,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")} { { } onPress={() => router.push("/settings/optimized-server/page")} showArrow - title="Optimized Versions Server" + title={t("home.settings.downloads.optimized_versions_server")} > diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx index d0ebd9df..5f0afdc5 100644 --- a/components/settings/Jellyseerr.tsx +++ b/components/settings/Jellyseerr.tsx @@ -2,6 +2,7 @@ import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; import { userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import { useAtom } from "jotai"; import { useState } from "react"; import { View } from "react-native"; @@ -20,6 +21,8 @@ export const JellyseerrSettings = () => { clearAllJellyseerData, } = useJellyseerr(); + const { t } = useTranslation(); + const [user] = useAtom(userAtom); const [settings, updateSettings, pluginSettings] = useSettings(); @@ -47,7 +50,7 @@ export const JellyseerrSettings = () => { updateSettings({ jellyseerrServerUrl }); }, onError: () => { - toast.error("Failed to login"); + toast.error(t("jellyseerr.failed_to_login")); }, onSettled: () => { setJellyseerrPassword(undefined); @@ -89,53 +92,50 @@ export const JellyseerrSettings = () => { <> ) : ( - This integration is in its early stages. Expect things to change. + {t("home.settings.plugins.jellyseerr.jellyseerr_warning")} - Server URL + {t("home.settings.plugins.jellyseerr.server_url")} - Example: http(s)://your-host.url - - - (add port if required) + {t("home.settings.plugins.jellyseerr.server_url_hint")} { marginBottom: 8, }} > - {promptForJellyseerrPass ? "Clear" : "Save"} + {promptForJellyseerrPass ? t("home.settings.plugins.jellyseerr.clear_button") : t("home.settings.plugins.jellyseerr.save_button")} { opacity: promptForJellyseerrPass ? 1 : 0.5, }} > - Password + {t("home.settings.plugins.jellyseerr.password")} { className="h-12 mt-2" onPress={() => loginToJellyseerrMutation.mutate()} > - Login + {t("home.settings.plugins.jellyseerr.login_button")} diff --git a/components/settings/MediaToggles.tsx b/components/settings/MediaToggles.tsx index 6283cb03..ae431ffb 100644 --- a/components/settings/MediaToggles.tsx +++ b/components/settings/MediaToggles.tsx @@ -3,12 +3,15 @@ import { ViewProps } from "react-native"; import { useSettings } from "@/utils/atoms/settings"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; +import { useTranslation } from "react-i18next"; import DisabledSetting from "@/components/settings/DisabledSetting"; import {Stepper} from "@/components/inputs/Stepper"; interface Props extends ViewProps {} export const MediaToggles: React.FC = ({ ...props }) => { + const { t } = useTranslation(); + const [settings, updateSettings, pluginSettings] = useSettings(); if (!settings) return null; @@ -25,16 +28,16 @@ export const MediaToggles: React.FC = ({ ...props }) => { disabled={disabled} {...props} > - + updateSettings({forwardSkipTime})} @@ -42,14 +45,14 @@ export const MediaToggles: React.FC = ({ ...props }) => { updateSettings({rewindSkipTime})} diff --git a/components/settings/OptimizedServerForm.tsx b/components/settings/OptimizedServerForm.tsx index 2aa7ebda..35910f04 100644 --- a/components/settings/OptimizedServerForm.tsx +++ b/components/settings/OptimizedServerForm.tsx @@ -1,5 +1,6 @@ import { TextInput, View, Linking } from "react-native"; import { Text } from "../common/Text"; +import { useTranslation } from "react-i18next"; interface Props { value: string; @@ -14,14 +15,16 @@ export const OptimizedServerForm: React.FC = ({ 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/OtherSettings.tsx b/components/settings/OtherSettings.tsx index 7c49a100..8ddbca48 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -15,6 +15,7 @@ import { toast } from "sonner-native"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; +import { useTranslation } from "react-i18next"; import DisabledSetting from "@/components/settings/DisabledSetting"; import Dropdown from "@/components/common/Dropdown"; @@ -22,6 +23,8 @@ export const OtherSettings: React.FC = () => { const router = useRouter(); const [settings, updateSettings, pluginSettings] = useSettings(); + const { t } = useTranslation(); + /******************** * Background task *******************/ @@ -74,9 +77,9 @@ export const OtherSettings: React.FC = () => { return ( - + { { title={ - {ScreenOrientationEnum[settings.defaultVideoOrientation]} + {t(ScreenOrientationEnum[settings.defaultVideoOrientation])} { /> } - label="Orientation" + label={t("home.settings.other.orientation")} onSelected={(defaultVideoOrientation) => updateSettings({ defaultVideoOrientation }) } @@ -121,7 +124,7 @@ export const OtherSettings: React.FC = () => { { Linking.openURL( @@ -152,11 +155,11 @@ export const OtherSettings: React.FC = () => { router.push("/settings/hide-libraries/page")} - title="Hide Libraries" + title={t("home.settings.other.hide_libraries")} showArrow /> { const [settings, updateSettings] = useSettings(); const router = useRouter(); + const { t } = useTranslation(); + 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 85a8259f..c3559b62 100644 --- a/components/settings/QuickConnect.tsx +++ b/components/settings/QuickConnect.tsx @@ -7,6 +7,7 @@ import { BottomSheetView, } from "@gorhom/bottom-sheet"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; +import { useTranslation } from "react-i18next"; import { useHaptic } from "@/hooks/useHaptic"; import { useAtom } from "jotai"; import React, { useCallback, useRef, useState } from "react"; @@ -26,6 +27,8 @@ export const QuickConnect: React.FC = ({ ...props }) => { const successHapticFeedback = useHaptic("success"); const errorHapticFeedback = useHaptic("error"); + const { t } = useTranslation(); + const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( = ({ ...props }) => { }); if (res.status === 200) { successHapticFeedback(); - Alert.alert("Success", "Quick connect authorized"); + Alert.alert(t("home.settings.quick_connect.success"), t("home.settings.quick_connect.quick_connect_autorized")); setQuickConnectCode(undefined); bottomSheetModalRef?.current?.close(); } else { errorHapticFeedback(); - Alert.alert("Error", "Invalid code"); + Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code")); } } catch (e) { errorHapticFeedback(); - Alert.alert("Error", "Invalid code"); + Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code")); } } }, [api, user, quickConnectCode]); return ( - + bottomSheetModalRef?.current?.present()} - title="Authorize Quick Connect" + title={t("home.settings.quick_connect.authorize_button")} textColor="blue" /> @@ -85,7 +88,7 @@ export const QuickConnect: React.FC = ({ ...props }) => { - Quick Connect + {t("home.settings.quick_connect.quick_connect_title")} @@ -93,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/StorageSettings.tsx b/components/settings/StorageSettings.tsx index ca7743e0..6b9c8444 100644 --- a/components/settings/StorageSettings.tsx +++ b/components/settings/StorageSettings.tsx @@ -7,9 +7,11 @@ import { View } from "react-native"; import { toast } from "sonner-native"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; +import { useTranslation } from "react-i18next"; export const StorageSettings = () => { const { deleteAllFiles, appSizeUsage } = useDownload(); + const { t } = useTranslation(); const successHapticFeedback = useHaptic("success"); const errorHapticFeedback = useHaptic("error"); @@ -31,7 +33,7 @@ export const StorageSettings = () => { successHapticFeedback(); } catch (e) { errorHapticFeedback(); - toast.error("Error deleting files"); + toast.error(t("home.settings.toasts.error_deleting_files")); } }; @@ -43,11 +45,10 @@ export const StorageSettings = () => { - Storage + {t("home.settings.storage.storage_title")} {size && ( - {Number(size.total - size.remaining).bytesToReadable()} of{" "} - {size.total?.bytesToReadable()} used + {t("home.settings.storage.size_used", {used: Number(size.total - size.remaining).bytesToReadable(), total: size.total?.bytesToReadable()})} )} @@ -78,18 +79,13 @@ export const StorageSettings = () => { - App {calculatePercentage(size.app, size.total)}% + {t("home.settings.storage.app_usage", {usedSpace: calculatePercentage(size.app, size.total)})} - Phone{" "} - {calculatePercentage( - size.total - size.remaining - size.app, - size.total - )} - % + {t("home.settings.storage.phone_usage", {availableSpace: calculatePercentage(size.total - size.remaining - size.app, size.total)})} @@ -100,7 +96,7 @@ export const StorageSettings = () => { diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index 6719171d..3748d0e5 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -7,6 +7,7 @@ import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; import { Ionicons } from "@expo/vector-icons"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; +import { useTranslation } from "react-i18next"; import {useSettings} from "@/utils/atoms/settings"; import {Stepper} from "@/components/inputs/Stepper"; import Dropdown from "@/components/common/Dropdown"; @@ -18,6 +19,7 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { const [_, __, pluginSettings] = useSettings(); const { settings, updateSettings } = media; const cultures = media.cultures; + const { t } = useTranslation(); if (!settings) return null; @@ -29,25 +31,33 @@ 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 ( - Configure subtitle preferences. + {t("home.settings.subtitles.subtitle_hint")} } > - + item?.ThreeLetterISOLanguageName ?? "unknown"} titleExtractor={(item) => item?.DisplayName} title={ - {settings?.defaultSubtitleLanguage?.DisplayName || "None"} + {settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")} = ({ ...props }) => { /> } - label="Languages" + label={t("home.settings.subtitles.language")} onSelected={(defaultSubtitleLanguage) => updateSettings({ - defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === "None" + defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === t("home.settings.subtitles.none") ? null : defaultSubtitleLanguage }) @@ -68,18 +78,18 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { t(subtitleModeKeys[item]) || String(item)} title={ - {settings?.subtitleMode || "Loading"} + {t(subtitleModeKeys[settings?.subtitleMode]) || t("home.settings.subtitles.loading")} = ({ ...props }) => { /> } - label="Subtitle Mode" + label={t("home.settings.subtitles.subtitle_mode")} onSelected={(subtitleMode) => updateSettings({subtitleMode}) } @@ -96,7 +106,7 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { = ({ ...props }) => { = ({ ...props }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); + const { t } = useTranslation(); const version = Application?.nativeApplicationVersion || @@ -21,11 +23,11 @@ export const UserInfo: React.FC = ({ ...props }) => { return ( - - - - - + + + + + ); 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..8a37659a 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("player.playback_state")} + {t("player.audio_tracks")} {audioTracks && audioTracks.map((track, index) => ( - {track.name} (Index: {track.index}) + {track.name} ({t("player.index")} {track.index}) ))} - Subtitle Tracks: + {t("player.subtitles_tracks")} {subtitleTracks && subtitleTracks.map((track, index) => ( - {track.name} (Index: {track.index}) + {track.name} ({t("player.index")} {track.index}) ))} = ({ playerRef, ...props }) => { } }} > - Refresh Tracks + {t("player.refresh_tracks")} ); diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index c6eb9a34..e56ab277 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"; import { CombinedCredit, PersonDetails, @@ -134,7 +135,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); } @@ -148,7 +149,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 + @@ -161,7 +162,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 { @@ -322,7 +323,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; }); @@ -422,14 +423,14 @@ export const useJellyseerr = () => { switch (mediaRequest.status) { case MediaRequestStatus.PENDING: case MediaRequestStatus.APPROVED: - toast.success(`Requested ${title}!`); - onSuccess?.(); + toast.success(t("jellyseerr.toasts.requested_item", {item: title})); + onSuccess?.() 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/i18n.ts b/i18n.ts new file mode 100644 index 00000000..edfe7202 --- /dev/null +++ b/i18n.ts @@ -0,0 +1,30 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; + +import en from "./translations/en.json"; +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: "v4", + resources: { + en: { translation: en }, + fr: { translation: fr }, + 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 f3cc630f..53d74e67 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "expo-keep-awake": "~13.0.2", "expo-linear-gradient": "~13.0.2", "expo-linking": "~6.3.1", + "expo-localization": "~16.0.0", "expo-network": "~6.0.1", "expo-notifications": "~0.28.19", "expo-router": "~3.5.24", @@ -67,11 +68,13 @@ "expo-web-browser": "~13.0.3", "ffmpeg-kit-react-native": "^6.0.2", "install": "^0.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.4.0", "react-native": "0.74.5", "react-native-awesome-slider": "^2.5.6", "react-native-circular-progress": "^1.4.1", diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 51f1f35c..149cdd96 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 { useHaptic } from "@/hooks/useHaptic"; 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(); @@ -224,9 +226,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(); @@ -275,10 +277,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(); @@ -300,7 +302,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: { @@ -357,9 +359,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(); @@ -377,21 +379,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}) ); } } @@ -407,11 +409,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_and_jobs_deleted_successfully")) ) .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/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 01a901a2..dddebb10 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -20,6 +20,7 @@ import React, { import { Platform } from "react-native"; import uuid from "react-native-uuid"; import { getDeviceName } from "react-native-device-info"; +import { useTranslation } from "react-i18next"; import { useSettings } from "@/utils/atoms/settings"; import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; @@ -50,6 +51,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const [jellyfin, setJellyfin] = useState(undefined); const [deviceId, setDeviceId] = useState(undefined); + const { t } = useTranslation(); + useEffect(() => { (async () => { const id = getOrSetDeviceId(); @@ -261,22 +264,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 new file mode 100644 index 00000000..df889eb3 --- /dev/null +++ b/translations/en.json @@ -0,0 +1,457 @@ +{ + "login": { + "username_required": "Username is required", + "error_title": "Error", + "login_title": "Log in", + "login_to_title": "Log in to", + "username_placeholder": "Username", + "password_placeholder": "Password", + "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.", + "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 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?" + }, + "server": { + "enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server", + "server_url_placeholder": "http(s)://your-server.com", + "connect_button": "Connect", + "previous_servers": "previous servers", + "clear_button": "Clear", + "search_for_local_servers": "Search for local servers", + "searching": "Searching...", + "servers": "Servers" + }, + "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!", + "error_message": "Something went wrong.\nPlease log out and in again.", + "continue_watching": "Continue Watching", + "next_up": "Next Up", + "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.", + "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", + "read_more": "Read more" + }, + "settings": { + "settings_title": "Settings", + "log_out_button": "Log out", + "user_info": { + "user_info_title": "User Info", + "user": "User", + "server": "Server", + "token": "Token", + "app_version": "App Version" + }, + "quick_connect": { + "quick_connect_title": "Quick Connect", + "authorize_button": "Authorize Quick Connect", + "enter_the_quick_connect_code": "Enter the quick connect code...", + "success": "Success", + "quick_connect_autorized": "Quick Connect authorized", + "error": "Error", + "invalid_code": "Invalid code", + "authorize": "Authorize" + }, + "media_controls": { + "media_controls_title": "Media Controls", + "forward_skip_length": "Forward skip 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", + "language": "Language" + }, + "subtitles": { + "subtitle_title": "Subtitles", + "subtitle_language": "Subtitle language", + "subtitle_mode": "Subtitle Mode", + "set_subtitle_track": "Set Subtitle Track From Previous Item", + "subtitle_size": "Subtitle Size", + "subtitle_hint": "Configure subtitle preference.", + "none": "None", + "language": "Language", + "loading": "Loading", + "modes": { + "Default": "Default", + "Smart": "Smart", + "Always": "Always", + "None": "None", + "OnlyForced": "OnlyForced" + } + }, + "other": { + "other_title": "Other", + "auto_rotate": "Auto rotate", + "video_orientation": "Video orientation", + "orientation": "Orientation", + "orientations": { + "DEFAULT": "Default", + "ALL": "All", + "PORTRAIT": "Portrait", + "PORTRAIT_UP": "Portrait Up", + "PORTRAIT_DOWN": "Portrait Down", + "LANDSCAPE": "Landscape", + "LANDSCAPE_LEFT": "Landscape Left", + "LANDSCAPE_RIGHT": "Landscape Right", + "OTHER": "Other", + "UNKNOWN": "Unknown" + }, + "safe_area_in_controls": "Safe area in controls", + "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", + "download_method": "Download method", + "remux_max_download": "Remux max download", + "auto_download": "Auto download", + "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", + "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...", + "password": "Password", + "password_placeholder": "Enter password for Jellyfin user {{username}}", + "save_button": "Save", + "clear_button": "Clear", + "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 ", + "url": "URL", + "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", + "toasts": { + "saved": "Saved" + } + } + }, + "storage": { + "storage_title": "Storage", + "app_usage": "App {{usedSpace}}%", + "phone_usage": "Phone {{availableSpace}}%", + "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", + "delete_all_logs": "Delete all logs" + }, + "languages": { + "title": "Languages", + "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", + "invalid_url": "Invalid URL" + } + }, + "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_all_button": "Delete all", + "active_download": "Active download", + "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 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 the 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!", + "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 downloading for {{item}}: {{message}}", + "server_responded_with_status_code": "Server responded with status {{statusCode}}", + "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 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" + } + } + }, + "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", + "episodes": "Episodes", + "collections": "Collections", + "actors": "Actors", + "request_movies": "Request Movies", + "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", + "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", + "list": "List", + "image_style": "Image style", + "poster": "Poster", + "cover": "Cover", + "show_titles": "Show titles", + "show_stats": "Show stats" + }, + "filters": { + "genres": "Genres", + "years": "Years", + "sort_by": "Sort By", + "sort_order": "Sort Order", + "tags": "Tags" + } + }, + "favorites": { + "series": "Series", + "movies": "Movies", + "episodes": "Episodes", + "videos": "Videos", + "boxsets": "Boxsets", + "playlists": "Playlists" + }, + "custom_links": { + "no_links": "No links" + }, + "player": { + "error": "Error", + "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 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...", + "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", + "no_items_to_display": "No items to display", + "cast_and_crew": "Cast & Crew", + "series": "Series", + "seasons": "Seasons", + "season": "Season", + "no_episodes_for_this_season": "No episodes for this season", + "overview": "Overview", + "more_with": "More with {{name}}", + "similar_items": "Similar items", + "no_similar_items_found": "No similar items found", + "video": "Video", + "more_details": "More details", + "quality": "Quality", + "audio": "Audio", + "subtitles": "Subtitle", + "show_more": "Show more", + "show_less": "Show less", + "appeared_in": "Appeared in", + "could_not_load_item": "Could not load item", + "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", + "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", + "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", + "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.", + "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", + "library": "Library", + "custom_links": "Custom Links", + "favorites": "Favorites" + } +} diff --git a/translations/fr.json b/translations/fr.json new file mode 100644 index 00000000..2ceb9546 --- /dev/null +++ b/translations/fr.json @@ -0,0 +1,457 @@ +{ + "login": { + "username_required": "Nom d'utilisateur requis", + "error_title": "Erreur", + "login_title": "Se connecter", + "login_to_title": "Se connecter à", + "username_placeholder": "Nom d'utilisateur", + "password_placeholder": "Mot de passe", + "login_button": "Se connecter", + "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 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.", + "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", + "server_url_placeholder": "http(s)://votre-serveur.com", + "connect_button": "Connexion", + "previous_servers": "Serveurs précédents", + "clear_button": "Effacer", + "search_for_local_servers": "Rechercher des serveurs locaux", + "searching": "Recherche...", + "servers": "Serveurs" + }, + "home": { + "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!", + "error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.", + "continue_watching": "Continuer à regarder", + "next_up": "À suivre", + "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.", + "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", + "read_more": "Lisez-en plus" + }, + "settings": { + "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": "Connexion Rapide", + "authorize_button": "Autoriser Connexion Rapide", + "enter_the_quick_connect_code": "Entrez le code Connexion Rapide...", + "success": "Succès", + "quick_connect_autorized": "Connexion Rapide autorisé", + "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", + "seconds_unit": "s" + }, + "audio": { + "audio_title": "Audio", + "set_audio_track": "Piste audio de l'élément précédent", + "audio_language": "Langue audio", + "audio_hint": "Choisissez une langue audio par défaut.", + "none": "Aucune", + "language": "Langage" + }, + "subtitles": { + "subtitle_title": "Sous-titres", + "subtitle_language": "Langue des sous-titres", + "subtitle_mode": "Mode des sous-titres", + "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", + "language": "Langage", + "loading": "Chargement", + "modes": { + "Default": "Par défaut", + "Smart": "Intelligent", + "Always": "Toujours", + "None": "Aucun", + "OnlyForced": "Forcés seulement" + } + }, + "other": { + "other_title": "Autres", + "auto_rotate": "Rotation automatique", + "video_orientation": "Orientation vidéo", + "orientation": "Orientation", + "orientations": { + "DEFAULT": "Par défaut", + "ALL": "Toutes", + "PORTRAIT": "Portrait", + "PORTRAIT_UP": "Portrait Haut", + "PORTRAIT_DOWN": "Portrait Bas", + "LANDSCAPE": "Paysage", + "LANDSCAPE_LEFT": "Paysage Gauche", + "LANDSCAPE_RIGHT": "Paysage Droite", + "OTHER": "Autre", + "UNKNOWN": "Inconnu" + }, + "safe_area_in_controls": "Zone de sécurité dans les contrôles", + "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", + "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", + "save_button": "Enregistrer", + "optimized_server": "Serveur optimisé", + "optimized": "Optimisé", + "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", + "server_url_placeholder": "http(s)://domaine.org:port" + }, + "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 de 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", + "toasts": { + "saved": "Enregistré" + } + } + }, + "storage": { + "storage_title": "Stockage", + "app_usage": "App {{usedSpace}}%", + "phone_usage": "Téléphone {{availableSpace}}%", + "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", + "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", + "invalid_url": "URL invalide" + } + }, + "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", + "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", + "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!", + "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_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", + "episodes": "Épisodes", + "collections": "Collections", + "actors": "Acteurs", + "request_movies": "Demander un film", + "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é", + "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", + "list": "Liste", + "image_style": "Style d'image", + "poster": "Affiche", + "cover": "Couverture", + "show_titles": "Afficher les titres", + "show_stats": "Afficher les statistiques" + }, + "filters": { + "genres": "Genres", + "years": "Années", + "sort_by": "Trier par", + "sort_order": "Ordre de tri", + "tags": "Tags" + } + }, + "favorites": { + "series": "Séries", + "movies": "Films", + "episodes": "Épisodes", + "videos": "Vidéos", + "boxsets": "Coffrets", + "playlists": "Listes de lecture" + }, + "custom_links": { + "no_links": "Aucun lien" + }, + "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!", + "no_video_source": "Aucune source vidéo...", + "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", + "no_items_to_display": "Aucun item à afficher", + "cast_and_crew": "Distribution et équipe", + "series": "Séries", + "seasons": "Saisons", + "season": "Saison", + "no_episodes_for_this_season": "Aucun épisode pour cette saison", + "overview": "Aperçu", + "more_with": "Plus avec {{name}}", + "similar_items": "Items similaires", + "no_similar_items_found": "Aucun item similaire trouvé", + "video": "Vidéo", + "more_details": "Plus de détails", + "quality": "Qualité", + "audio": "Audio", + "subtitles": "Sous-titres", + "show_more": "Afficher plus", + "show_less": "Afficher moins", + "appeared_in": "Apparu dans", + "could_not_load_item": "Impossible de charger l'item", + "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", + "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", + "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", + "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", + "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", + "search": "Recherche", + "library": "Bibliothèque", + "custom_links": "Liens personnalisés", + "favorites": "Favoris" + } +} diff --git a/translations/sv.json b/translations/sv.json new file mode 100644 index 00000000..d35f6c82 --- /dev/null +++ b/translations/sv.json @@ -0,0 +1,30 @@ +{ + "login": { + "username_required": "Användarnamn krävs", + "error_title": "Fel", + "login_title": "Logga in", + "username_placeholder": "Användarnamn", + "password_placeholder": "Lösenord", + "login_button": "Logga in" + }, + "server": { + "server_url_placeholder": "Server URL", + "connect_button": "Anslut" + }, + "home": { + "home": "Hem", + "no_internet": "Ingen Internet", + "no_internet_message": "Ingen fara, du kan fortfarande titta\npå nedladdat innehåll.", + "go_to_downloads": "Gå till nedladdningar", + "oops": "Hoppsan!", + "error_message": "Något gick fel.\nLogga ut och in igen.", + "continue_watching": "Fortsätt titta", + "next_up": "Nästa upp", + "recently_added_in": "Nyligen tillagt i {{libraryName}}" + }, + "tabs": { + "home": "Hem", + "search": "Sök", + "library": "Bibliotek" + } +} diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 0ee313a0..7483c039 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -28,16 +28,16 @@ export const ScreenOrientationEnum: Record< ScreenOrientation.OrientationLock, string > = { - [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[] = [ @@ -107,6 +107,7 @@ export type Settings = { forceLandscapeInVideoPlayer?: boolean; deviceProfile?: "Expo" | "Native" | "Old"; mediaListCollectionIds?: string[]; + preferedLanguage?: string; searchEngine: "Marlin" | "Jellyfin"; marlinServerUrl?: string; openInVLC?: boolean; @@ -153,6 +154,7 @@ const loadSettings = (): Settings => { forceLandscapeInVideoPlayer: false, deviceProfile: "Expo", mediaListCollectionIds: [], + preferedLanguage: undefined, searchEngine: "Jellyfin", marlinServerUrl: "", openInVLC: false,