Merge pull request #340 from simoncaron/feat/i18n

Implement translation with i18next
This commit is contained in:
Fredrik Burmester
2025-01-23 10:01:13 +01:00
committed by GitHub
87 changed files with 1653 additions and 420 deletions

View File

@@ -105,6 +105,7 @@
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching." "motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
} }
], ],
"expo-localization",
"expo-asset", "expo-asset",
[ [
"react-native-edge-to-edge", "react-native-edge-to-edge",

View File

@@ -1,7 +1,9 @@
import {Stack} from "expo-router"; import {Stack} from "expo-router";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
export default function CustomMenuLayout() { export default function CustomMenuLayout() {
const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
@@ -9,7 +11,7 @@ export default function CustomMenuLayout() {
options={{ options={{
headerShown: true, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: "Custom Links", headerTitle: t("tabs.custom_links"),
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,

View File

@@ -7,6 +7,7 @@ import { ListItem } from "@/components/list/ListItem";
import * as WebBrowser from "expo-web-browser"; import * as WebBrowser from "expo-web-browser";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTranslation } from "react-i18next";
export interface MenuLink { export interface MenuLink {
name: string; name: string;
@@ -18,6 +19,7 @@ export default function menuLinks() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]); const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
const { t } = useTranslation();
const getMenuLinks = useCallback(async () => { const getMenuLinks = useCallback(async () => {
try { try {
@@ -67,7 +69,7 @@ export default function menuLinks() {
)} )}
ListEmptyComponent={ ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full"> <View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No links</Text> <Text className="font-bold text-xl text-neutral-500">{t("custom_links.no_links")}</Text>
</View> </View>
} }
/> />

View File

@@ -1,8 +1,10 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
export default function SearchLayout() { export default function SearchLayout() {
const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
@@ -10,7 +12,7 @@ export default function SearchLayout() {
options={{ options={{
headerShown: true, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: "Favorites", headerTitle: t("tabs.favorites"),
headerLargeStyle: { headerLargeStyle: {
backgroundColor: "black", backgroundColor: "black",
}, },

View File

@@ -4,9 +4,11 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageSta
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useTranslation } from "react-i18next";
export default function IndexLayout() { export default function IndexLayout() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
@@ -14,7 +16,7 @@ export default function IndexLayout() {
options={{ options={{
headerShown: true, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: "Home", headerTitle: t("tabs.home"),
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerLargeStyle: { headerLargeStyle: {
backgroundColor: "black", backgroundColor: "black",
@@ -38,19 +40,19 @@ export default function IndexLayout() {
<Stack.Screen <Stack.Screen
name="downloads/index" name="downloads/index"
options={{ options={{
title: "Downloads", title: t("home.downloads.downloads_title"),
}} }}
/> />
<Stack.Screen <Stack.Screen
name="downloads/[seriesId]" name="downloads/[seriesId]"
options={{ options={{
title: "TV-Series", title: t("home.downloads.tvseries"),
}} }}
/> />
<Stack.Screen <Stack.Screen
name="settings" name="settings"
options={{ options={{
title: "Settings", title: t("home.settings.settings_title"),
}} }}
/> />
<Stack.Screen <Stack.Screen

View File

@@ -12,6 +12,8 @@ import React, { useEffect, useMemo, useRef } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native"; import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { t } from 'i18next';
import { DownloadSize } from "@/components/downloads/DownloadSize"; import { DownloadSize } from "@/components/downloads/DownloadSize";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
@@ -24,6 +26,7 @@ import { writeToLog } from "@/utils/log";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload(); const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
const router = useRouter(); const router = useRouter();
@@ -70,17 +73,17 @@ export default function page() {
const deleteMovies = () => const deleteMovies = () =>
deleteFileByType("Movie") deleteFileByType("Movie")
.then(() => toast.success("Deleted all movies successfully!")) .then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
.catch((reason) => { .catch((reason) => {
writeToLog("ERROR", reason); writeToLog("ERROR", reason);
toast.error("Failed to delete all movies"); toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
}); });
const deleteShows = () => const deleteShows = () =>
deleteFileByType("Episode") deleteFileByType("Episode")
.then(() => toast.success("Deleted all TV-Series successfully!")) .then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
.catch((reason) => { .catch((reason) => {
writeToLog("ERROR", 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 () => const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows()]); await Promise.all([deleteMovies(), deleteShows()]);
@@ -98,9 +101,9 @@ export default function page() {
<View className="mb-4 flex flex-col space-y-4 px-4"> <View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === DownloadMethod.Remux && ( {settings?.downloadMethod === DownloadMethod.Remux && (
<View className="bg-neutral-900 p-4 rounded-2xl"> <View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text> <Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
<Text className="text-xs opacity-70 text-red-600"> <Text className="text-xs opacity-70 text-red-600">
Queue and active downloads will be lost on app restart {t("home.downloads.queue_hint")}
</Text> </Text>
<View className="flex flex-col space-y-2 mt-2"> <View className="flex flex-col space-y-2 mt-2">
{queue.map((q, index) => ( {queue.map((q, index) => (
@@ -133,7 +136,7 @@ export default function page() {
</View> </View>
{queue.length === 0 && ( {queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text> <Text className="opacity-50">{t("home.downloads.no_items_in_queue")}</Text>
)} )}
</View> </View>
)} )}
@@ -144,7 +147,7 @@ export default function page() {
{movies.length > 0 && ( {movies.length > 0 && (
<View className="mb-4"> <View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4"> <View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">Movies</Text> <Text className="text-lg font-bold">{t("home.downloads.movies")}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center"> <View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text> <Text className="text-xs font-bold">{movies?.length}</Text>
</View> </View>
@@ -163,7 +166,7 @@ export default function page() {
{groupedBySeries.length > 0 && ( {groupedBySeries.length > 0 && (
<View className="mb-4"> <View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4"> <View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">TV-Series</Text> <Text className="text-lg font-bold">{t("home.downloads.tvseries")}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center"> <View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold"> <Text className="text-xs font-bold">
{groupedBySeries?.length} {groupedBySeries?.length}
@@ -189,7 +192,7 @@ export default function page() {
)} )}
{downloadedFiles?.length === 0 && ( {downloadedFiles?.length === 0 && (
<View className="flex px-4"> <View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text> <Text className="opacity-50">{t("home.downloads.no_downloaded_items")}</Text>
</View> </View>
)} )}
</View> </View>
@@ -214,13 +217,13 @@ export default function page() {
<BottomSheetView> <BottomSheetView>
<View className="p-4 space-y-4 mb-4"> <View className="p-4 space-y-4 mb-4">
<Button color="purple" onPress={deleteMovies}> <Button color="purple" onPress={deleteMovies}>
Delete all Movies {t("home.downloads.delete_all_movies_button")}
</Button> </Button>
<Button color="purple" onPress={deleteShows}> <Button color="purple" onPress={deleteShows}>
Delete all TV-Series {t("home.downloads.delete_all_tvseries_button")}
</Button> </Button>
<Button color="red" onPress={deleteAllMedia}> <Button color="red" onPress={deleteAllMedia}>
Delete all {t("home.downloads.delete_all_button")}
</Button> </Button>
</View> </View>
</BottomSheetView> </BottomSheetView>
@@ -233,15 +236,15 @@ function migration_20241124() {
const router = useRouter(); const router = useRouter();
const { deleteAllFiles } = useDownload(); const { deleteAllFiles } = useDownload();
Alert.alert( Alert.alert(
"New app version requires re-download", t("home.downloads.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_description"),
[ [
{ {
text: "Back", text: t("home.downloads.back"),
onPress: () => router.back(), onPress: () => router.back(),
}, },
{ {
text: "Delete", text: t("home.downloads.delete"),
style: "destructive", style: "destructive",
onPress: async () => await deleteAllFiles(), onPress: async () => await deleteAllFiles(),
}, },

View File

@@ -27,6 +27,7 @@ import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
RefreshControl, RefreshControl,
@@ -55,6 +56,8 @@ type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() { export default function index() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
@@ -204,7 +207,7 @@ export default function index() {
const latestMediaViews = collections.map((c) => { const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] = const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = "Recently Added in " + c.Name; const title = t("home.recently_added_in", {libraryName: c.Name});
const queryKey = [ const queryKey = [
"home", "home",
"recentlyAddedIn" + c.CollectionType, "recentlyAddedIn" + c.CollectionType,
@@ -221,7 +224,7 @@ export default function index() {
const ss: Section[] = [ const ss: Section[] = [
{ {
title: "Continue Watching", title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"], queryKey: ["home", "resumeItems"],
queryFn: async () => queryFn: async () =>
( (
@@ -235,7 +238,7 @@ export default function index() {
orientation: "horizontal", orientation: "horizontal",
}, },
{ {
title: "Next Up", title: t("home.next_up"),
queryKey: ["home", "nextUp-all"], queryKey: ["home", "nextUp-all"],
queryFn: async () => queryFn: async () =>
( (
@@ -262,7 +265,7 @@ export default function index() {
// } as Section) // } as Section)
// ) || []), // ) || []),
{ {
title: "Suggested Movies", title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id], queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () => queryFn: async () =>
( (
@@ -277,7 +280,7 @@ export default function index() {
orientation: "vertical", orientation: "vertical",
}, },
{ {
title: "Suggested Episodes", title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id], queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => { queryFn: async () => {
try { try {
@@ -347,9 +350,9 @@ export default function index() {
if (isConnected === false) { if (isConnected === false) {
return ( return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8"> <View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">No Internet</Text> <Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
<Text className="text-center opacity-70"> <Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content. {t("home.no_internet_message")}
</Text> </Text>
<View className="mt-4"> <View className="mt-4">
<Button <Button
@@ -360,7 +363,7 @@ export default function index() {
<Ionicons name="arrow-forward" size={20} color="white" /> <Ionicons name="arrow-forward" size={20} color="white" />
} }
> >
Go to downloads {t("home.go_to_downloads")}
</Button> </Button>
<Button <Button
color="black" color="black"
@@ -389,10 +392,8 @@ export default function index() {
if (e1) if (e1)
return ( return (
<View className="flex flex-col items-center justify-center h-full -mt-6"> <View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text> <Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70"> <Text className="text-center opacity-70">{t("home.error_message")}</Text>
Something went wrong.{"\n"}Please log out and in again.
</Text>
</View> </View>
); );

View File

@@ -5,10 +5,12 @@ import { Feather, Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useFocusEffect, useRouter } from "expo-router"; import { useFocusEffect, useRouter } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
import {useTranslation } from "react-i18next";
import { Linking, TouchableOpacity, View } from "react-native"; import { Linking, TouchableOpacity, View } from "react-native";
export default function page() { export default function page() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation();
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -20,18 +22,17 @@ export default function page() {
<View className="bg-neutral-900 h-full py-16 px-4 space-y-8"> <View className="bg-neutral-900 h-full py-16 px-4 space-y-8">
<View> <View>
<Text className="text-3xl font-bold text-center mb-2"> <Text className="text-3xl font-bold text-center mb-2">
Welcome to Streamyfin {t("home.intro.welcome_to_streamyfin")}
</Text> </Text>
<Text className="text-center"> <Text className="text-center">
A free and open source client for Jellyfin. {t("home.intro.a_free_and_open_source_client_for_jellyfin")}
</Text> </Text>
</View> </View>
<View> <View>
<Text className="text-lg font-bold">Features</Text> <Text className="text-lg font-bold">{t("home.intro.features_title")}</Text>
<Text className="text-xs"> <Text className="text-xs">
Streamyfin has a bunch of features and integrates with a wide array of {t("home.intro.features_description")}
software which you can find in the settings menu, these include:
</Text> </Text>
<View className="flex flex-row items-center mt-4"> <View className="flex flex-row items-center mt-4">
<Image <Image
@@ -44,8 +45,7 @@ export default function page() {
<View className="shrink ml-2"> <View className="shrink ml-2">
<Text className="font-bold mb-1">Jellyseerr</Text> <Text className="font-bold mb-1">Jellyseerr</Text>
<Text className="shrink text-xs"> <Text className="shrink text-xs">
Connect to your Jellyseerr instance and request movies directly in {t("home.intro.jellyseerr_feature_description")}
the app.
</Text> </Text>
</View> </View>
</View> </View>
@@ -60,11 +60,9 @@ export default function page() {
<Ionicons name="cloud-download-outline" size={32} color="white" /> <Ionicons name="cloud-download-outline" size={32} color="white" />
</View> </View>
<View className="shrink ml-2"> <View className="shrink ml-2">
<Text className="font-bold mb-1">Downloads</Text> <Text className="font-bold mb-1">{t("home.intro.downloads_feature_title")}</Text>
<Text className="shrink text-xs"> <Text className="shrink text-xs">
Download movies and tv-shows to view offline. Use either the {t("home.intro.downloads_feature_description")}
default method or install the optimize server to download files in
the background.
</Text> </Text>
</View> </View>
</View> </View>
@@ -81,7 +79,7 @@ export default function page() {
<View className="shrink ml-2"> <View className="shrink ml-2">
<Text className="font-bold mb-1">Chromecast</Text> <Text className="font-bold mb-1">Chromecast</Text>
<Text className="shrink text-xs"> <Text className="shrink text-xs">
Cast movies and tv-shows to your Chromecast devices. {t("home.intro.chromecast_feature_description")}
</Text> </Text>
</View> </View>
</View> </View>
@@ -96,11 +94,9 @@ export default function page() {
<Feather name="settings" size={28} color={"white"} /> <Feather name="settings" size={28} color={"white"} />
</View> </View>
<View className="shrink ml-2"> <View className="shrink ml-2">
<Text className="font-bold mb-1">Centralised Settings Plugin</Text> <Text className="font-bold mb-1">{t("home.intro.centralised_settings_plugin_title")}</Text>
<Text className="shrink text-xs"> <Text className="shrink text-xs">
Configure settings from a centralised location on your Jellyfin {t("home.intro.centralised_settings_plugin_description")}{" "}
server. All client settings for all users will be synced
automatically.{" "}
<Text <Text
className="text-purple-600" className="text-purple-600"
onPress={() => { onPress={() => {
@@ -109,7 +105,7 @@ export default function page() {
); );
}} }}
> >
Read more {t("home.intro.read_more")}
</Text> </Text>
</Text> </Text>
</View> </View>
@@ -122,7 +118,7 @@ export default function page() {
}} }}
className="mt-4" className="mt-4"
> >
Done {t("home.intro.done_button")}
</Button> </Button>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
@@ -131,7 +127,7 @@ export default function page() {
}} }}
className="mt-4" className="mt-4"
> >
<Text className="text-purple-600 text-center">Go to settings</Text> <Text className="text-purple-600 text-center">{t("home.intro.go_to_settings_button")}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>

View File

@@ -10,11 +10,13 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect"; import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings"; import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles"; import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { UserInfo } from "@/components/settings/UserInfo"; import { UserInfo } from "@/components/settings/UserInfo";
import { useJellyfin } from "@/providers/JellyfinProvider"; import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log"; import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native"; import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -40,7 +42,7 @@ export default function settings() {
logout(); logout();
}} }}
> >
<Text className="text-red-600">Log out</Text> <Text className="text-red-600">{t("home.settings.log_out_button")}</Text>
</TouchableOpacity> </TouchableOpacity>
), ),
}); });
@@ -68,33 +70,35 @@ export default function settings() {
<PluginSettings /> <PluginSettings />
<AppLanguageSelector/>
<ListGroup title={"Intro"}> <ListGroup title={"Intro"}>
<ListItem <ListItem
onPress={() => { onPress={() => {
router.push("/intro/page"); router.push("/intro/page");
}} }}
title={"Show intro"} title={t("home.settings.intro.show_intro")}
/> />
<ListItem <ListItem
textColor="red" textColor="red"
onPress={() => { onPress={() => {
storage.set("hasShownIntro", false); storage.set("hasShownIntro", false);
}} }}
title={"Reset intro"} title={t("home.settings.intro.reset_intro")}
/> />
</ListGroup> </ListGroup>
<View className="mb-4"> <View className="mb-4">
<ListGroup title={"Logs"}> <ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem <ListItem
onPress={() => router.push("/settings/logs/page")} onPress={() => router.push("/settings/logs/page")}
showArrow showArrow
title={"Logs"} title={t("home.settings.logs.logs_title")}
/> />
<ListItem <ListItem
textColor="red" textColor="red"
onPress={onClearLogsClicked} onPress={onClearLogsClicked}
title={"Delete All Logs"} title={t("home.settings.logs.delete_all_logs")}
/> />
</ListGroup> </ListGroup>
</View> </View>

View File

@@ -8,6 +8,7 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Switch, View } from "react-native"; import { Switch, View } from "react-native";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() { export default function page() {
@@ -15,6 +16,8 @@ export default function page() {
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const { data, isLoading: isLoading } = useQuery({ const { data, isLoading: isLoading } = useQuery({
queryKey: ["user-views", user?.Id], queryKey: ["user-views", user?.Id],
queryFn: async () => { queryFn: async () => {
@@ -57,8 +60,7 @@ export default function page() {
))} ))}
</ListGroup> </ListGroup>
<Text className="px-4 text-xs text-neutral-500 mt-1"> <Text className="px-4 text-xs text-neutral-500 mt-1">
Select the libraries you want to hide from the Library tab and home page {t("home.settings.other.select_liraries_you_want_to_hide")}
sections.
</Text> </Text>
</DisabledSetting> </DisabledSetting>
); );

View File

@@ -1,9 +1,11 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useLog } from "@/utils/log"; import { useLog } from "@/utils/log";
import { ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import { useTranslation } from "react-i18next";
export default function page() { export default function page() {
const { logs } = useLog(); const { logs } = useLog();
const { t } = useTranslation();
return ( return (
<ScrollView className="p-4"> <ScrollView className="p-4">
@@ -25,7 +27,7 @@ export default function page() {
</View> </View>
))} ))}
{logs?.length === 0 && ( {logs?.length === 0 && (
<Text className="opacity-50">No logs available</Text> <Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
)} )}
</View> </View>
</ScrollView> </ScrollView>

View File

@@ -4,6 +4,8 @@ import { ListItem } from "@/components/list/ListItem";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useTranslation } from "react-i18next";
import React, {useEffect, useMemo, useState} from "react"; import React, {useEffect, useMemo, useState} from "react";
import { import {
Linking, Linking,
@@ -18,6 +20,8 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -27,7 +31,7 @@ export default function page() {
updateSettings({ updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1), marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
}); });
toast.success("Saved"); toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
}; };
const handleOpenLink = () => { const handleOpenLink = () => {
@@ -43,7 +47,7 @@ export default function page() {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)}> <TouchableOpacity onPress={() => onSave(value)}>
<Text className="text-blue-500">Save</Text> <Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
</TouchableOpacity> </TouchableOpacity>
), ),
}); });
@@ -63,7 +67,7 @@ export default function page() {
showText={!pluginSettings?.marlinServerUrl?.locked} showText={!pluginSettings?.marlinServerUrl?.locked}
> >
<ListItem <ListItem
title={"Enable Marlin Search"} title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
onPress={() => { onPress={() => {
updateSettings({ searchEngine: "Jellyfin" }); updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
@@ -88,11 +92,11 @@ export default function page() {
<View <View
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`} className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
> >
<Text className="mr-4">URL</Text> <Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
<TextInput <TextInput
editable={settings.searchEngine === "Marlin"} editable={settings.searchEngine === "Marlin"}
className="text-white" className="text-white"
placeholder="http(s)://domain.org:port" placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
value={value} value={value}
keyboardType="url" keyboardType="url"
returnKeyType="done" returnKeyType="done"
@@ -103,10 +107,9 @@ export default function page() {
</View> </View>
</DisabledSetting> </DisabledSetting>
<Text className="px-4 text-xs text-neutral-500 mt-1"> <Text className="px-4 text-xs text-neutral-500 mt-1">
Enter the URL for the Marlin server. The URL should include http or {t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
https and optionally the port.{" "}
<Text className="text-blue-500" onPress={handleOpenLink}> <Text className="text-blue-500" onPress={handleOpenLink}>
Read more about Marlin. {t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text> </Text>
</Text> </Text>
</DisabledSetting> </DisabledSetting>

View File

@@ -10,11 +10,14 @@ import { useAtom } from "jotai";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [settings, updateSettings, pluginSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
@@ -24,7 +27,7 @@ export default function page() {
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async (newVal: string) => { mutationFn: async (newVal: string) => {
if (newVal.length === 0 || !newVal.startsWith("http")) { if (newVal.length === 0 || !newVal.startsWith("http")) {
toast.error("Invalid URL"); toast.error(t("home.settings.toasts.invalid_url"));
return; return;
} }
@@ -42,13 +45,13 @@ export default function page() {
}, },
onSuccess: (data) => { onSuccess: (data) => {
if (data) { if (data) {
toast.success("Connected"); toast.success(t("home.settings.toasts.connected"));
} else { } else {
toast.error("Could not connect"); toast.error(t("home.settings.toasts.could_not_connect"));
} }
}, },
onError: () => { onError: () => {
toast.error("Could not connect"); toast.error(t("home.settings.toasts.could_not_connect"));
}, },
}); });
@@ -59,13 +62,13 @@ export default function page() {
useEffect(() => { useEffect(() => {
if (!pluginSettings?.optimizedVersionsServerUrl?.locked) { if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
navigation.setOptions({ navigation.setOptions({
title: "Optimized Server", title: t("home.settings.downloads.optimized_server"),
headerRight: () => headerRight: () =>
saveMutation.isPending ? ( saveMutation.isPending ? (
<ActivityIndicator size={"small"} color={"white"} /> <ActivityIndicator size={"small"} color={"white"} />
) : ( ) : (
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}> <TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
<Text className="text-blue-500">Save</Text> <Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
</TouchableOpacity> </TouchableOpacity>
), ),
}); });

View File

@@ -18,10 +18,12 @@ import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useTranslation } from "react-i18next";
const page: React.FC = () => { const page: React.FC = () => {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { actorId } = local as { actorId: string }; const { actorId } = local as { actorId: string };
const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -110,7 +112,7 @@ const page: React.FC = () => {
</View> </View>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100"> <Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
Appeared In {t("item_card.appeared_in")}
</Text> </Text>
<InfiniteHorizontalScroll <InfiniteHorizontalScroll
height={247} height={247}

View File

@@ -33,6 +33,7 @@ import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FlatList, View } from "react-native"; import { FlatList, View } from "react-native";
import { useTranslation } from "react-i18next";
const page: React.FC = () => { const page: React.FC = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
@@ -45,6 +46,8 @@ const page: React.FC = () => {
ScreenOrientation.Orientation.PORTRAIT_UP ScreenOrientation.Orientation.PORTRAIT_UP
); );
const { t } = useTranslation();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
@@ -244,7 +247,7 @@ const page: React.FC = () => {
}} }}
set={setSelectedGenres} set={setSelectedGenres}
values={selectedGenres} values={selectedGenres}
title="Genres" title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -271,7 +274,7 @@ const page: React.FC = () => {
}} }}
set={setSelectedYears} set={setSelectedYears}
values={selectedYears} values={selectedYears}
title="Years" title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)} searchFilter={(item, search) => item.includes(search)}
/> />
@@ -296,7 +299,7 @@ const page: React.FC = () => {
}} }}
set={setSelectedTags} set={setSelectedTags}
values={selectedTags} values={selectedTags}
title="Tags" title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -314,7 +317,7 @@ const page: React.FC = () => {
queryFn={async () => sortOptions.map((s) => s.key)} queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy} set={setSortBy}
values={sortBy} values={sortBy}
title="Sort By" title={t("library.filters.sort_by")}
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || "" sortOptions.find((i) => i.key === item)?.value || ""
} }
@@ -334,7 +337,7 @@ const page: React.FC = () => {
queryFn={async () => sortOrderOptions.map((s) => s.key)} queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder} set={setSortOrder}
values={sortOrder} values={sortOrder}
title="Sort Order" title={t("library.filters.sort_order")}
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || "" sortOrderOptions.find((i) => i.key === item)?.value || ""
} }
@@ -374,7 +377,7 @@ const page: React.FC = () => {
<FlashList <FlashList
ListEmptyComponent={ ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full"> <View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text> <Text className="font-bold text-xl text-neutral-500">{t("search.no_results")}</Text>
</View> </View>
} }
extraData={[ extraData={[

View File

@@ -13,11 +13,13 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useTranslation } from "react-i18next";
const Page: React.FC = () => { const Page: React.FC = () => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { id } = useLocalSearchParams() as { id: string }; const { id } = useLocalSearchParams() as { id: string };
const { t } = useTranslation();
const { data: item, isError } = useQuery({ const { data: item, isError } = useQuery({
queryKey: ["item", id], queryKey: ["item", id],
@@ -74,7 +76,7 @@ const Page: React.FC = () => {
if (isError) if (isError)
return ( return (
<View className="flex flex-col items-center justify-center h-screen w-screen"> <View className="flex flex-col items-center justify-center h-screen w-screen">
<Text>Could not load item</Text> <Text>{t("item_card.could_not_load_item")}</Text>
</View> </View>
); );

View File

@@ -17,6 +17,7 @@ import {
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search"; import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
@@ -39,6 +40,8 @@ import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/request
const Page: React.FC = () => { const Page: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { t } = useTranslation();
const { mediaTitle, releaseYear, posterSrc, ...result } = const { mediaTitle, releaseYear, posterSrc, ...result } =
params as unknown as { params as unknown as {
mediaTitle: string; mediaTitle: string;
@@ -214,7 +217,7 @@ const Page: React.FC = () => {
<Button loading={true} disabled={true} color="purple"></Button> <Button loading={true} disabled={true} color="purple"></Button>
) : canRequest ? ( ) : canRequest ? (
<Button color="purple" onPress={request}> <Button color="purple" onPress={request}>
Request {t("jellyseerr.request_button")}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -229,7 +232,7 @@ const Page: React.FC = () => {
borderStyle: "solid", borderStyle: "solid",
}} }}
> >
Report issue {t("jellyseerr.report_issue_button")}
</Button> </Button>
)} )}
<OverviewText text={result.overview} className="mt-4" /> <OverviewText text={result.overview} className="mt-4" />
@@ -281,7 +284,7 @@ const Page: React.FC = () => {
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2"> <View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View> <View>
<Text className="font-bold text-2xl text-neutral-100"> <Text className="font-bold text-2xl text-neutral-100">
Whats wrong? {t("jellyseerr.whats_wrong")}
</Text> </Text>
</View> </View>
<View className="flex flex-col space-y-2 items-start"> <View className="flex flex-col space-y-2 items-start">
@@ -290,13 +293,13 @@ const Page: React.FC = () => {
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col"> <View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs"> <Text className="opacity-50 mb-1 text-xs">
Issue Type {t("jellyseerr.issue_type")}
</Text> </Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}> <Text style={{}} className="" numberOfLines={1}>
{issueType {issueType
? IssueTypeName[issueType] ? IssueTypeName[issueType]
: "Select an issue"} : t("jellyseerr.select_an_issue")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -310,7 +313,7 @@ const Page: React.FC = () => {
collisionPadding={0} collisionPadding={0}
sideOffset={0} sideOffset={0}
> >
<DropdownMenu.Label>Types</DropdownMenu.Label> <DropdownMenu.Label>{t("jellyseerr.types")}</DropdownMenu.Label>
{Object.entries(IssueTypeName) {Object.entries(IssueTypeName)
.reverse() .reverse()
.map(([key, value], idx) => ( .map(([key, value], idx) => (
@@ -335,7 +338,7 @@ const Page: React.FC = () => {
maxLength={254} maxLength={254}
style={{ color: "white" }} style={{ color: "white" }}
clearButtonMode="always" clearButtonMode="always"
placeholder="(optional) Describe the issue..." placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor="#9CA3AF" placeholderTextColor="#9CA3AF"
// Issue with multiline + Textinput inside a portal // Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668 // https://github.com/callstack/react-native-paper/issues/1668
@@ -345,7 +348,7 @@ const Page: React.FC = () => {
</View> </View>
</View> </View>
<Button className="mt-auto" onPress={submitIssue} color="purple"> <Button className="mt-auto" onPress={submitIssue} color="purple">
Submit {t("jellyseerr.submit_button")}
</Button> </Button>
</View> </View>
</BottomSheetView> </BottomSheetView>

View File

@@ -13,9 +13,12 @@ import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow"; import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import { useTranslation } from "react-i18next";
export default function page() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser } = useJellyseerr(); const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
const { personId } = local as { personId: string }; const { personId } = local as { personId: string };
@@ -58,7 +61,7 @@ export default function page() {
<ParallaxSlideShow <ParallaxSlideShow
data={castedRoles} data={castedRoles}
images={backdrops} images={backdrops}
listHeader="Appearances" listHeader={t("jellyseerr.appearances")}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
logo={ logo={
<Image <Image
@@ -85,7 +88,7 @@ export default function page() {
{data?.details?.name} {data?.details?.name}
</Text> </Text>
<Text className="opacity-50"> <Text className="opacity-50">
Born{" "} {t("jellyseerr.born")}{" "}
{new Date(data?.details?.birthday!!).toLocaleDateString( {new Date(data?.details?.birthday!!).toLocaleDateString(
`${locale}-${region}`, `${locale}-${region}`,
{ {

View File

@@ -17,6 +17,7 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
const HOUR_HEIGHT = 30; const HOUR_HEIGHT = 30;
const ITEMS_PER_PAGE = 20; const ITEMS_PER_PAGE = 20;
@@ -177,6 +178,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
onNextPage, onNextPage,
isNextDisabled, isNextDisabled,
}) => { }) => {
const { t } = useTranslation();
return ( return (
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2"> <View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
<TouchableOpacity <TouchableOpacity
@@ -194,7 +196,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
currentPage === 1 ? "text-gray-500" : "text-white" currentPage === 1 ? "text-gray-500" : "text-white"
}`} }`}
> >
Previous {t("live_tv.previous")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<Text className="text-white">Page {currentPage}</Text> <Text className="text-white">Page {currentPage}</Text>
@@ -206,7 +208,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
<Text <Text
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`} className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
> >
Next {t("live_tv.next")}
</Text> </Text>
<Ionicons <Ionicons
name="chevron-forward" name="chevron-forward"

View File

@@ -7,12 +7,15 @@ import { useAtom } from "jotai";
import React from "react"; import React from "react";
import { ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
export default function page() { export default function page() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
return ( return (
<ScrollView <ScrollView
nestedScrollEnabled nestedScrollEnabled
@@ -28,7 +31,7 @@ export default function page() {
<View className="flex flex-col space-y-2"> <View className="flex flex-col space-y-2">
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "recommended"]} queryKey={["livetv", "recommended"]}
title={"On now"} title={t("live_tv.on_now")}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getRecommendedPrograms({ const res = await getLiveTvApi(api).getRecommendedPrograms({
@@ -46,7 +49,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "shows"]} queryKey={["livetv", "shows"]}
title={"Shows"} title={t("live_tv.shows")}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -68,7 +71,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "movies"]} queryKey={["livetv", "movies"]}
title={"Movies"} title={t("live_tv.movies")}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -86,7 +89,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "sports"]} queryKey={["livetv", "sports"]}
title={"Sports"} title={t("live_tv.sports")}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -104,7 +107,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "kids"]} queryKey={["livetv", "kids"]}
title={"For Kids"} title={t("live_tv.for_kids")}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -122,7 +125,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "news"]} queryKey={["livetv", "news"]}
title={"News"} title={t("live_tv.news")}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({

View File

@@ -1,11 +1,13 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import React from "react"; import React from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useTranslation } from "react-i18next";
export default function page() { export default function page() {
const { t } = useTranslation();
return ( return (
<View className="flex items-center justify-center h-full -mt-12"> <View className="flex items-center justify-center h-full -mt-12">
<Text>Coming soon</Text> <Text>{t("live_tv.coming_soon")}</Text>
</View> </View>
); );
} }

View File

@@ -16,9 +16,11 @@ import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useTranslation } from "react-i18next";
const page: React.FC = () => { const page: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as { const { id: seriesId, seasonIndex } = params as {
id: string; id: string;
@@ -85,7 +87,7 @@ const page: React.FC = () => {
<AddToFavorites item={item} type="series" /> <AddToFavorites item={item} type="series" />
<DownloadItems <DownloadItems
size="large" size="large"
title="Download Series" title={t("item_card.download.download_series")}
items={allEpisodes || []} items={allEpisodes || []}
MissingDownloadIconComponent={() => ( MissingDownloadIconComponent={() => (
<Ionicons name="download" size={22} color="white" /> <Ionicons name="download" size={22} color="white" />

View File

@@ -41,6 +41,7 @@ import {
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
const Page = () => { const Page = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
@@ -62,6 +63,8 @@ const Page = () => {
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference); const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sop) { if (sop) {
@@ -298,7 +301,7 @@ const Page = () => {
}} }}
set={setSelectedGenres} set={setSelectedGenres}
values={selectedGenres} values={selectedGenres}
title="Genres" title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -325,7 +328,7 @@ const Page = () => {
}} }}
set={setSelectedYears} set={setSelectedYears}
values={selectedYears} values={selectedYears}
title="Years" title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)} searchFilter={(item, search) => item.includes(search)}
/> />
@@ -350,7 +353,7 @@ const Page = () => {
}} }}
set={setSelectedTags} set={setSelectedTags}
values={selectedTags} values={selectedTags}
title="Tags" title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -368,7 +371,7 @@ const Page = () => {
queryFn={async () => sortOptions.map((s) => s.key)} queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy} set={setSortBy}
values={sortBy} values={sortBy}
title="Sort By" title={t("library.filters.sort_by")}
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || "" sortOptions.find((i) => i.key === item)?.value || ""
} }
@@ -388,7 +391,7 @@ const Page = () => {
queryFn={async () => sortOrderOptions.map((s) => s.key)} queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder} set={setSortOrder}
values={sortOrder} values={sortOrder}
title="Sort Order" title={t("library.filters.sort_order")}
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || "" sortOrderOptions.find((i) => i.key === item)?.value || ""
} }
@@ -434,7 +437,7 @@ const Page = () => {
if (flatData.length === 0) if (flatData.length === 0)
return ( return (
<View className="h-full w-full flex justify-center items-center"> <View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">No items found</Text> <Text className="text-lg text-neutral-500">{t("library.no_items_found")}</Text>
</View> </View>
); );
@@ -443,7 +446,7 @@ const Page = () => {
key={orientation} key={orientation}
ListEmptyComponent={ ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full"> <View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text> <Text className="font-bold text-xl text-neutral-500">{t("library.no_results")}</Text>
</View> </View>
} }
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"

View File

@@ -4,10 +4,13 @@ import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { Platform } from "react-native"; import { Platform } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { useTranslation } from "react-i18next";
export default function IndexLayout() { export default function IndexLayout() {
const [settings, updateSettings, pluginSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const { t } = useTranslation();
if (!settings?.libraryOptions) return null; if (!settings?.libraryOptions) return null;
return ( return (
@@ -17,7 +20,7 @@ export default function IndexLayout() {
options={{ options={{
headerShown: true, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: "Library", headerTitle: t("tabs.library"),
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerLargeStyle: { headerLargeStyle: {
backgroundColor: "black", backgroundColor: "black",
@@ -43,11 +46,11 @@ export default function IndexLayout() {
side={"bottom"} side={"bottom"}
sideOffset={10} sideOffset={10}
> >
<DropdownMenu.Label>Display</DropdownMenu.Label> <DropdownMenu.Label>{t("library.options.display")}</DropdownMenu.Label>
<DropdownMenu.Group key="display-group"> <DropdownMenu.Group key="display-group">
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger"> <DropdownMenu.SubTrigger key="image-style-trigger">
Display {t("library.options.display")}
</DropdownMenu.SubTrigger> </DropdownMenu.SubTrigger>
<DropdownMenu.SubContent <DropdownMenu.SubContent
alignOffset={-10} alignOffset={-10}
@@ -70,7 +73,7 @@ export default function IndexLayout() {
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-1"> <DropdownMenu.ItemTitle key="display-title-1">
Row {t("library.options.row")}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
@@ -87,14 +90,14 @@ export default function IndexLayout() {
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-2"> <DropdownMenu.ItemTitle key="display-title-2">
List {t("library.options.list")}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
</DropdownMenu.Sub> </DropdownMenu.Sub>
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger"> <DropdownMenu.SubTrigger key="image-style-trigger">
Image style {t("library.options.image_style")}
</DropdownMenu.SubTrigger> </DropdownMenu.SubTrigger>
<DropdownMenu.SubContent <DropdownMenu.SubContent
alignOffset={-10} alignOffset={-10}
@@ -117,7 +120,7 @@ export default function IndexLayout() {
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="poster-title"> <DropdownMenu.ItemTitle key="poster-title">
Poster {t("library.options.poster")}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
@@ -134,7 +137,7 @@ export default function IndexLayout() {
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="cover-title"> <DropdownMenu.ItemTitle key="cover-title">
Cover {t("library.options.cover")}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
@@ -158,7 +161,7 @@ export default function IndexLayout() {
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-titles-title"> <DropdownMenu.ItemTitle key="show-titles-title">
Show titles {t("library.options.show_titles")}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
@@ -175,7 +178,7 @@ export default function IndexLayout() {
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-stats-title"> <DropdownMenu.ItemTitle key="show-stats-title">
Show stats {t("library.options.show_stats")}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
</DropdownMenu.Group> </DropdownMenu.Group>

View File

@@ -13,6 +13,7 @@ import { useAtom } from "jotai";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { StyleSheet, View } from "react-native"; import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
export default function index() { export default function index() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -20,6 +21,8 @@ export default function index() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [settings] = useSettings(); const [settings] = useSettings();
const { t } = useTranslation();
const { data, isLoading: isLoading } = useQuery({ const { data, isLoading: isLoading } = useQuery({
queryKey: ["user-views", user?.Id], queryKey: ["user-views", user?.Id],
queryFn: async () => { queryFn: async () => {
@@ -70,7 +73,7 @@ export default function index() {
if (!libraries) if (!libraries)
return ( return (
<View className="h-full w-full flex justify-center items-center"> <View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">No libraries found</Text> <Text className="text-lg text-neutral-500">{t("library.no_libraries_found")}</Text>
</View> </View>
); );

View File

@@ -4,8 +4,10 @@ import {
} from "@/components/stacks/NestedTabPageStack"; } from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
export default function SearchLayout() { export default function SearchLayout() {
const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
@@ -13,7 +15,7 @@ export default function SearchLayout() {
options={{ options={{
headerShown: true, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: "Search", headerTitle: t("tabs.search"),
headerLargeStyle: { headerLargeStyle: {
backgroundColor: "black", backgroundColor: "black",
}, },

View File

@@ -31,6 +31,7 @@ import React, {
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { useTranslation } from "react-i18next";
type SearchType = "Library" | "Discover"; type SearchType = "Library" | "Discover";
@@ -47,6 +48,8 @@ export default function search() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { q, prev } = params as { q: string; prev: Href<string> }; const { q, prev } = params as { q: string; prev: Href<string> };
const [searchType, setSearchType] = useState<SearchType>("Library"); const [searchType, setSearchType] = useState<SearchType>("Library");
@@ -122,7 +125,7 @@ export default function search() {
if (Platform.OS === "ios") if (Platform.OS === "ios")
navigation.setOptions({ navigation.setOptions({
headerSearchBarOptions: { headerSearchBarOptions: {
placeholder: "Search...", placeholder: t("search.search"),
onChangeText: (e: any) => { onChangeText: (e: any) => {
router.setParams({ q: "" }); router.setParams({ q: "" });
setSearch(e.nativeEvent.text); setSearch(e.nativeEvent.text);
@@ -214,7 +217,7 @@ export default function search() {
autoCorrect={false} autoCorrect={false}
returnKeyType="done" returnKeyType="done"
keyboardType="web-search" keyboardType="web-search"
placeholder="Search here..." placeholder={t("search.search_here")}
value={search} value={search}
onChangeText={(text) => setSearch(text)} onChangeText={(text) => setSearch(text)}
/> />
@@ -224,7 +227,7 @@ export default function search() {
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2"> <View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<TouchableOpacity onPress={() => setSearchType("Library")}> <TouchableOpacity onPress={() => setSearchType("Library")}>
<Tag <Tag
text="Library" text={t("search.library")}
textClass="p-1" textClass="p-1"
className={ className={
searchType === "Library" ? "bg-purple-600" : undefined searchType === "Library" ? "bg-purple-600" : undefined
@@ -233,7 +236,7 @@ export default function search() {
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType("Discover")}> <TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag <Tag
text="Discover" text={t("search.discover")}
textClass="p-1" textClass="p-1"
className={ className={
searchType === "Discover" ? "bg-purple-600" : undefined searchType === "Discover" ? "bg-purple-600" : undefined
@@ -250,7 +253,7 @@ export default function search() {
{searchType === "Library" ? ( {searchType === "Library" ? (
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}> <View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper <SearchItemWrapper
header="Movies" header={t("search.movies")}
ids={movies?.map((m) => m.Id!)} ids={movies?.map((m) => m.Id!)}
renderItem={(item: BaseItemDto) => ( renderItem={(item: BaseItemDto) => (
<TouchableItemRouter <TouchableItemRouter
@@ -270,7 +273,7 @@ export default function search() {
/> />
<SearchItemWrapper <SearchItemWrapper
ids={series?.map((m) => m.Id!)} ids={series?.map((m) => m.Id!)}
header="Series" header={t("search.series")}
renderItem={(item: BaseItemDto) => ( renderItem={(item: BaseItemDto) => (
<TouchableItemRouter <TouchableItemRouter
key={item.Id} key={item.Id}
@@ -289,7 +292,7 @@ export default function search() {
/> />
<SearchItemWrapper <SearchItemWrapper
ids={episodes?.map((m) => m.Id!)} ids={episodes?.map((m) => m.Id!)}
header="Episodes" header={t("search.episodes")}
renderItem={(item: BaseItemDto) => ( renderItem={(item: BaseItemDto) => (
<TouchableItemRouter <TouchableItemRouter
item={item} item={item}
@@ -303,7 +306,7 @@ export default function search() {
/> />
<SearchItemWrapper <SearchItemWrapper
ids={collections?.map((m) => m.Id!)} ids={collections?.map((m) => m.Id!)}
header="Collections" header={t("search.collections")}
renderItem={(item: BaseItemDto) => ( renderItem={(item: BaseItemDto) => (
<TouchableItemRouter <TouchableItemRouter
key={item.Id} key={item.Id}
@@ -319,7 +322,7 @@ export default function search() {
/> />
<SearchItemWrapper <SearchItemWrapper
ids={actors?.map((m) => m.Id!)} ids={actors?.map((m) => m.Id!)}
header="Actors" header={t("search.actors")}
renderItem={(item: BaseItemDto) => ( renderItem={(item: BaseItemDto) => (
<TouchableItemRouter <TouchableItemRouter
item={item} item={item}
@@ -341,7 +344,7 @@ export default function search() {
{!loading && noResults && debouncedSearch.length > 0 ? ( {!loading && noResults && debouncedSearch.length > 0 ? (
<View> <View>
<Text className="text-center text-lg font-bold mt-4"> <Text className="text-center text-lg font-bold mt-4">
No results found for {t("search.no_results_found_for")}
</Text> </Text>
<Text className="text-xs text-purple-600 text-center"> <Text className="text-xs text-purple-600 text-center">
"{debouncedSearch}" "{debouncedSearch}"

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useRef } from "react"; import React, { useCallback, useRef } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router"; import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
@@ -30,6 +31,7 @@ export const NativeTabs = withLayoutContext<
export default function TabLayout() { export default function TabLayout() {
const [settings] = useSettings(); const [settings] = useSettings();
const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
useFocusEffect( useFocusEffect(
@@ -61,7 +63,7 @@ export default function TabLayout() {
<NativeTabs.Screen <NativeTabs.Screen
name="(home)" name="(home)"
options={{ options={{
title: "Home", title: t("tabs.home"),
tabBarIcon: tabBarIcon:
Platform.OS == "android" Platform.OS == "android"
? ({ color, focused, size }) => ? ({ color, focused, size }) =>
@@ -75,7 +77,7 @@ export default function TabLayout() {
<NativeTabs.Screen <NativeTabs.Screen
name="(search)" name="(search)"
options={{ options={{
title: "Search", title: t("tabs.search"),
tabBarIcon: tabBarIcon:
Platform.OS == "android" Platform.OS == "android"
? ({ color, focused, size }) => ? ({ color, focused, size }) =>
@@ -89,7 +91,7 @@ export default function TabLayout() {
<NativeTabs.Screen <NativeTabs.Screen
name="(favorites)" name="(favorites)"
options={{ options={{
title: "Favorites", title: t("tabs.favorites"),
tabBarIcon: tabBarIcon:
Platform.OS == "android" Platform.OS == "android"
? ({ color, focused, size }) => ? ({ color, focused, size }) =>
@@ -105,7 +107,7 @@ export default function TabLayout() {
<NativeTabs.Screen <NativeTabs.Screen
name="(libraries)" name="(libraries)"
options={{ options={{
title: "Library", title: t("tabs.library"),
tabBarIcon: tabBarIcon:
Platform.OS == "android" Platform.OS == "android"
? ({ color, focused, size }) => ? ({ color, focused, size }) =>
@@ -119,7 +121,7 @@ export default function TabLayout() {
<NativeTabs.Screen <NativeTabs.Screen
name="(custom-links)" name="(custom-links)"
options={{ options={{
title: "Custom Links", title: t("tabs.custom_links"),
// @ts-expect-error // @ts-expect-error
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true, tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarIcon: tabBarIcon:

View File

@@ -48,12 +48,14 @@ import {
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings"; import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() { export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null); const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
@@ -161,7 +163,7 @@ export default function page() {
const { mediaSource, sessionId, url } = res; const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) { 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; return null;
} }
@@ -426,7 +428,7 @@ export default function page() {
if (isErrorItem || isErrorStreamUrl) if (isErrorItem || isErrorStreamUrl)
return ( return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black"> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text> <Text className="text-white">{t("player.error")}</Text>
</View> </View>
); );
@@ -465,8 +467,8 @@ export default function page() {
onVideoError={(e) => { onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(
"Error", t("player.error"),
"An error occurred while playing the video. Check logs in settings." t("player.an_error_occured_while_playing_the_video")
); );
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}

View File

@@ -39,12 +39,14 @@ import Video, {
VideoRef, VideoRef,
} from "react-native-video"; } from "react-native-video";
import { SubtitleHelper } from "@/utils/SubtitleHelper"; import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next";
const Player = () => { const Player = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null); const videoRef = useRef<VideoRef | null>(null);
const { t } = useTranslation();
const firstTime = useRef(true); const firstTime = useRef(true);
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -374,7 +376,7 @@ const Player = () => {
if (isErrorItem || isErrorStreamUrl) if (isErrorItem || isErrorStreamUrl)
return ( return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black"> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text> <Text className="text-white">{t("player.error")}</Text>
</View> </View>
); );
@@ -440,7 +442,7 @@ const Player = () => {
/> />
</> </>
) : ( ) : (
<Text>No video source...</Text> <Text>{t("player.no_video_source")}</Text>
)} )}
</View> </View>

View File

@@ -40,6 +40,9 @@ import { useEffect, useRef } from "react";
import { Appearance, AppState, TouchableOpacity } from "react-native"; import { Appearance, AppState, TouchableOpacity } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; 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 "react-native-reanimated";
import { Toaster } from "sonner-native"; import { Toaster } from "sonner-native";
@@ -228,7 +231,9 @@ export default function RootLayout() {
return ( return (
<JotaiProvider> <JotaiProvider>
<Layout /> <I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</JotaiProvider> </JotaiProvider>
); );
} }
@@ -252,6 +257,8 @@ function Layout() {
useKeepAwake(); useKeepAwake();
useNotificationObserver(); useNotificationObserver();
const { i18n } = useTranslation();
useEffect(() => { useEffect(() => {
checkAndRequestPermissions(); checkAndRequestPermissions();
}, []); }, []);
@@ -265,6 +272,12 @@ function Layout() {
); );
}, [settings]); }, [settings]);
useEffect(() => {
i18n.changeLanguage(
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
);
}, [settings?.preferedLanguage, i18n]);
const appState = useRef(AppState.currentState); const appState = useRef(AppState.currentState);
useEffect(() => { useEffect(() => {

View File

@@ -21,12 +21,11 @@ import {
} from "react-native"; } from "react-native";
import { z } from "zod"; import { z } from "zod";
import { t } from 'i18next';
const CredentialsSchema = z.object({ 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 } = const { setServer, login, removeServer, initiateQuickConnect } =
useJellyfin(); useJellyfin();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -80,7 +79,7 @@ const Login: React.FC = () => {
className="flex flex-row items-center" className="flex flex-row items-center"
> >
<Ionicons name="chevron-back" size={18} color={Colors.primary} /> <Ionicons name="chevron-back" size={18} color={Colors.primary} />
<Text className="ml-2 text-purple-600">Change server</Text> <Text className="ml-2 text-purple-600">{t("login.change_server")}</Text>
</TouchableOpacity> </TouchableOpacity>
) : null, ) : null,
}); });
@@ -97,9 +96,9 @@ const Login: React.FC = () => {
} }
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
Alert.alert("Connection failed", error.message); Alert.alert(t("login.connection_failed"), error.message);
} else { } else {
Alert.alert("Connection failed", "An unexpected error occurred"); Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
} }
} finally { } finally {
setLoading(false); setLoading(false);
@@ -168,8 +167,8 @@ const Login: React.FC = () => {
if (result === undefined) { if (result === undefined) {
Alert.alert( Alert.alert(
"Connection failed", t("login.connection_failed"),
"Could not connect to the server. Please check the URL and your network connection." t("login.could_not_connect_to_server")
); );
return; return;
} }
@@ -181,14 +180,14 @@ const Login: React.FC = () => {
try { try {
const code = await initiateQuickConnect(); const code = await initiateQuickConnect();
if (code) { 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) { } 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 = () => {
<View className="flex flex-col h-full relative items-center justify-center"> <View className="flex flex-col h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full"> <View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2"> <View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold -mb-2"> <Text className="text-2xl font-bold -mb-2">
Log in <>
<> {serverName ? (
{serverName ? ( <>
<> {t("login.login_to_title") + " "}
{" to "} <Text className="text-purple-600">{serverName}</Text>
<Text className="text-purple-600">{serverName}</Text> </>
</> ) : t("login.login_title")}
) : null} </>
</> </Text>
</Text>
<Text className="text-xs text-neutral-400"> <Text className="text-xs text-neutral-400">
{api.basePath} {api.basePath}
</Text> </Text>
<Input <Input
placeholder="Username" placeholder={t("login.username_placeholder")}
onChangeText={(text) => onChangeText={(text) =>
setCredentials({ ...credentials, username: text }) setCredentials({ ...credentials, username: text })
} }
@@ -233,7 +231,7 @@ const Login: React.FC = () => {
/> />
<Input <Input
placeholder="Password" placeholder={t("login.password_placeholder")}
onChangeText={(text) => onChangeText={(text) =>
setCredentials({ ...credentials, password: text }) setCredentials({ ...credentials, password: text })
} }
@@ -252,7 +250,7 @@ const Login: React.FC = () => {
loading={loading} loading={loading}
className="flex-1 mr-2" className="flex-1 mr-2"
> >
Log in {t("login.login_button")}
</Button> </Button>
<TouchableOpacity <TouchableOpacity
onPress={handleQuickConnect} onPress={handleQuickConnect}
@@ -286,11 +284,11 @@ const Login: React.FC = () => {
/> />
<Text className="text-3xl font-bold">Streamyfin</Text> <Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500"> <Text className="text-neutral-500">
Enter the URL to your Jellyfin server {t("server.enter_url_to_jellyfin_server")}
</Text> </Text>
<Input <Input
aria-label="Server URL" aria-label="Server URL"
placeholder="http(s)://your-server.com" placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL} onChangeText={setServerURL}
value={serverURL} value={serverURL}
keyboardType="url" keyboardType="url"
@@ -299,14 +297,13 @@ const Login: React.FC = () => {
textContentType="URL" textContentType="URL"
maxLength={500} maxLength={500}
/> />
<Button <Button
loading={loadingServerCheck} loading={loadingServerCheck}
disabled={loadingServerCheck} disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)} onPress={async () => await handleConnect(serverURL)}
className="w-full grow" className="w-full grow"
> >
Connect {t("server.connect_button")}
</Button> </Button>
<JellyfinServerDiscovery <JellyfinServerDiscovery
onServerSelect={(server) => { onServerSelect={(server) => {

BIN
bun.lockb

Binary file not shown.

View File

@@ -3,6 +3,7 @@ import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -26,6 +27,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
[audioStreams, selected] [audioStreams, selected]
); );
const { t } = useTranslation();
return ( return (
<View <View
className="flex shrink" className="flex shrink"
@@ -36,7 +39,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Audio</Text> <Text className="opacity-50 mb-1 text-xs">{t("item_card.audio")}</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="" numberOfLines={1}> <Text className="" numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle} {selectedAudioSteam?.DisplayTitle}

View File

@@ -2,6 +2,7 @@ import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export type Bitrate = { export type Bitrate = {
key: string; key: string;
@@ -63,6 +64,8 @@ export const BitrateSelector: React.FC<Props> = ({
); );
}, []); }, []);
const { t } = useTranslation();
return ( return (
<View <View
className="flex shrink" className="flex shrink"
@@ -74,7 +77,7 @@ export const BitrateSelector: React.FC<Props> = ({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Quality</Text> <Text className="opacity-50 mb-1 text-xs">{t("item_card.quality")}</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}> <Text style={{}} className="" numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key} {BITRATES.find((b) => b.value === selected?.value)?.key}

View File

@@ -32,6 +32,7 @@ import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle"; import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { t } from "i18next";
interface DownloadProps extends ViewProps { interface DownloadProps extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
@@ -55,6 +56,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const { processes, startBackgroundDownload, downloadedFiles } = useDownload(); const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
const { startRemuxing } = useRemuxHlsToMp4(); const { startRemuxing } = useRemuxHlsToMp4();
@@ -160,7 +162,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
); );
} }
} else { } else {
toast.error("You are not allowed to download files."); toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files"));
} }
}, [ }, [
queue, queue,
@@ -212,8 +214,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!res) { if (!res) {
Alert.alert( Alert.alert(
"Something went wrong", t("home.downloads.something_went_wrong"),
"Could not get stream url from Jellyfin" t("home.downloads.could_not_get_stream_url_from_jellyfin")
); );
continue; continue;
} }
@@ -330,7 +332,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
{title} {title}
</Text> </Text>
<Text className="text-neutral-300"> <Text className="text-neutral-300">
{subtitle || `Download ${itemsNotDownloaded.length} items`} {subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})}
</Text> </Text>
</View> </View>
<View className="flex flex-col space-y-2 w-full items-start"> <View className="flex flex-col space-y-2 w-full items-start">
@@ -368,13 +370,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
onPress={acceptDownloadOptions} onPress={acceptDownloadOptions}
color="purple" color="purple"
> >
Download {t("item_card.download.download_button")}
</Button> </Button>
<View className="opacity-70 text-center w-full flex items-center"> <View className="opacity-70 text-center w-full flex items-center">
<Text className="text-xs"> <Text className="text-xs">
{usingOptimizedServer {usingOptimizedServer
? "Using optimized server" ? t("item_card.download.using_optimized_server")
: "Using default method"} : t("item_card.download.using_default_method")}
</Text> </Text>
</View> </View>
</View> </View>
@@ -391,7 +393,9 @@ export const DownloadSingleItem: React.FC<{
return ( return (
<DownloadItems <DownloadItems
size={size} size={size}
title="Download Episode" title={item.Type == "Episode"
? t("item_card.download.download_episode")
: t("item_card.download.download_movie")}
subtitle={item.Name!} subtitle={item.Name!}
items={[item]} items={[item]}
MissingDownloadIconComponent={() => ( MissingDownloadIconComponent={() => (

View File

@@ -15,6 +15,7 @@ import {
BottomSheetScrollView, BottomSheetScrollView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { Button } from "./Button"; import { Button } from "./Button";
import { useTranslation } from "react-i18next";
interface Props { interface Props {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -22,15 +23,16 @@ interface Props {
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => { export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
return ( return (
<View className="px-4 mt-2 mb-4"> <View className="px-4 mt-2 mb-4">
<Text className="text-lg font-bold mb-4">Video</Text> <Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}> <TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
<View className="flex flex-row space-x-2"> <View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} /> <VideoStreamInfo source={source} />
</View> </View>
<Text className="text-purple-600">More details</Text> <Text className="text-purple-600">{t("item_card.more_details")}</Text>
</TouchableOpacity> </TouchableOpacity>
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
@@ -52,14 +54,14 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
<BottomSheetScrollView> <BottomSheetScrollView>
<View className="flex flex-col space-y-2 p-4 mb-4"> <View className="flex flex-col space-y-2 p-4 mb-4">
<View className=""> <View className="">
<Text className="text-lg font-bold mb-4">Video</Text> <Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
<View className="flex flex-row space-x-2"> <View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} /> <VideoStreamInfo source={source} />
</View> </View>
</View> </View>
<View className=""> <View className="">
<Text className="text-lg font-bold mb-2">Audio</Text> <Text className="text-lg font-bold mb-2">{t("item_card.audio")}</Text>
<AudioStreamInfo <AudioStreamInfo
audioStreams={ audioStreams={
source?.MediaStreams?.filter( source?.MediaStreams?.filter(
@@ -70,7 +72,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
</View> </View>
<View className=""> <View className="">
<Text className="text-lg font-bold mb-2">Subtitles</Text> <Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text>
<SubtitleStreamInfo <SubtitleStreamInfo
subtitleStreams={ subtitleStreams={
source?.MediaStreams?.filter( source?.MediaStreams?.filter(

View File

@@ -4,6 +4,7 @@ import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
import { Button } from "./Button"; import { Button } from "./Button";
import { ListGroup } from "./list/ListGroup"; import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem"; import { ListItem } from "./list/ListItem";
import { useTranslation } from "react-i18next";
interface Props { interface Props {
onServerSelect?: (server: { address: string; serverName?: string }) => void; onServerSelect?: (server: { address: string; serverName?: string }) => void;
@@ -11,17 +12,18 @@ interface Props {
const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => { const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => {
const { servers, isSearching, startDiscovery } = useJellyfinDiscovery(); const { servers, isSearching, startDiscovery } = useJellyfinDiscovery();
const { t } = useTranslation();
return ( return (
<View className="mt-2"> <View className="mt-2">
<Button onPress={startDiscovery} color="black"> <Button onPress={startDiscovery} color="black">
<Text className="text-white text-center"> <Text className="text-white text-center">
{isSearching ? "Searching..." : "Search for local servers"} {isSearching ? t("server.searching") : t("server.search_for_local_servers")}
</Text> </Text>
</Button> </Button>
{servers.length ? ( {servers.length ? (
<ListGroup title="Servers" className="mt-4"> <ListGroup title={t("server.servers")} className="mt-4">
{servers.map((server) => ( {servers.map((server) => (
<ListItem <ListItem
key={server.address} key={server.address}

View File

@@ -6,6 +6,7 @@ import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto; item: BaseItemDto;
@@ -27,6 +28,8 @@ export const MediaSourceSelector: React.FC<Props> = ({
[item, selected] [item, selected]
); );
const { t } = useTranslation();
const commonPrefix = useMemo(() => { const commonPrefix = useMemo(() => {
const mediaSources = item.MediaSources || []; const mediaSources = item.MediaSources || [];
if (!mediaSources.length) return ""; if (!mediaSources.length) return "";
@@ -58,7 +61,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Video</Text> <Text className="opacity-50 mb-1 text-xs">{t("item_card.video")}</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
<Text numberOfLines={1}>{selectedName}</Text> <Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity> </TouchableOpacity>

View File

@@ -11,6 +11,7 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps { interface Props extends ViewProps {
actorId: string; actorId: string;
@@ -24,6 +25,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { t } = useTranslation();
const { data: actor } = useQuery({ const { data: actor } = useQuery({
queryKey: ["actor", actorId], queryKey: ["actor", actorId],
@@ -76,7 +78,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
return ( return (
<View {...props}> <View {...props}>
<Text className="text-lg font-bold mb-2 px-4"> <Text className="text-lg font-bold mb-2 px-4">
More with {actor?.Name} {t("item_card.more_with", {name: actor?.Name})}
</Text> </Text>
<HorizontalScroll <HorizontalScroll
data={items} data={items}

View File

@@ -2,6 +2,7 @@ import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { tc } from "@/utils/textTools"; import { tc } from "@/utils/textTools";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps { interface Props extends ViewProps {
text?: string | null; text?: string | null;
@@ -14,12 +15,13 @@ export const OverviewText: React.FC<Props> = ({
...props ...props
}) => { }) => {
const [limit, setLimit] = useState(characterLimit); const [limit, setLimit] = useState(characterLimit);
const { t } = useTranslation();
if (!text) return null; if (!text) return null;
return ( return (
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<Text className="text-lg font-bold mb-2">Overview</Text> <Text className="text-lg font-bold mb-2">{t("item_card.overview")}</Text>
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() =>
setLimit((prev) => setLimit((prev) =>
@@ -31,7 +33,7 @@ export const OverviewText: React.FC<Props> = ({
<Text>{tc(text, limit)}</Text> <Text>{tc(text, limit)}</Text>
{text.length > characterLimit && ( {text.length > characterLimit && (
<Text className="text-purple-600 mt-1"> <Text className="text-purple-600 mt-1">
{limit === characterLimit ? "Show more" : "Show less"} {limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")}
</Text> </Text>
)} )}
</View> </View>

View File

@@ -32,6 +32,7 @@ import Animated, {
import { Button } from "./Button"; import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent"; import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast"; import { chromecastProfile } from "@/utils/profiles/chromecast";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
@@ -50,6 +51,7 @@ export const PlayButton: React.FC<Props> = ({
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const { t } = useTranslation();
const [colorAtom] = useAtom(itemThemeColorAtom); const [colorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
@@ -132,8 +134,8 @@ export const PlayButton: React.FC<Props> = ({
if (!data?.url) { if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data); console.warn("No URL returned from getStreamUrl", data);
Alert.alert( Alert.alert(
"Client error", t("player.client_error"),
"Could not create stream for Chromecast" t("player.could_not_create_stream_for_chromecast")
); );
return; return;
} }

View File

@@ -3,6 +3,7 @@ import { View } from "react-native";
import { useMMKVString } from "react-native-mmkv"; import { useMMKVString } from "react-native-mmkv";
import { ListGroup } from "./list/ListGroup"; import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem"; import { ListItem } from "./list/ListItem";
import { useTranslation } from "react-i18next";
interface Server { interface Server {
address: string; address: string;
@@ -22,11 +23,13 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
return JSON.parse(_previousServers || "[]") as Server[]; return JSON.parse(_previousServers || "[]") as Server[];
}, [_previousServers]); }, [_previousServers]);
const { t } = useTranslation();
if (!previousServers.length) return null; if (!previousServers.length) return null;
return ( return (
<View> <View>
<ListGroup title="previous servers" className="mt-4"> <ListGroup title={t("server.previous_servers")} className="mt-4">
{previousServers.map((s) => ( {previousServers.map((s) => (
<ListItem <ListItem
key={s.address} key={s.address}
@@ -39,7 +42,7 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
onPress={() => { onPress={() => {
setPreviousServers("[]"); setPreviousServers("[]");
}} }}
title={"Clear"} title={t("server.clear_button")}
textColor="red" textColor="red"
/> />
</ListGroup> </ListGroup>

View File

@@ -12,6 +12,7 @@ import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { HorizontalScroll } from "./common/HorrizontalScroll"; import { HorizontalScroll } from "./common/HorrizontalScroll";
import { TouchableItemRouter } from "./common/TouchableItemRouter"; import { TouchableItemRouter } from "./common/TouchableItemRouter";
import { useTranslation } from "react-i18next";
interface SimilarItemsProps extends ViewProps { interface SimilarItemsProps extends ViewProps {
itemId?: string | null; itemId?: string | null;
@@ -23,6 +24,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { t } = useTranslation();
const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({ const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({
queryKey: ["similarItems", itemId], queryKey: ["similarItems", itemId],
@@ -47,12 +49,12 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
return ( return (
<View {...props}> <View {...props}>
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text> <Text className="px-4 text-lg font-bold mb-2">{t("item_card.similar_items")}</Text>
<HorizontalScroll <HorizontalScroll
data={movies} data={movies}
loading={isLoading} loading={isLoading}
height={247} height={247}
noItemsText="No similar items found" noItemsText={t("item_card.no_similar_items_found")}
renderItem={(item: BaseItemDto, idx: number) => ( renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter <TouchableItemRouter
key={idx} key={idx}

View File

@@ -5,6 +5,7 @@ import { Platform, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { SubtitleHelper } from "@/utils/SubtitleHelper"; import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -37,6 +38,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
if (subtitleStreams.length === 0) return null; if (subtitleStreams.length === 0) return null;
const { t } = useTranslation();
return ( return (
<View <View
className="flex col shrink justify-start place-self-start items-start" className="flex col shrink justify-start place-self-start items-start"
@@ -48,12 +51,12 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col " {...props}> <View className="flex flex-col " {...props}>
<Text className="opacity-50 mb-1 text-xs">Subtitle</Text> <Text className="opacity-50 mb-1 text-xs">{t("item_card.subtitles")}</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className=" "> <Text className=" ">
{selectedSubtitleSteam {selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7) ? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: "None"} : t("item_card.none")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View File

@@ -15,6 +15,7 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { Text } from "./Text"; import { Text } from "./Text";
import { t } from "i18next";
interface HorizontalScrollProps interface HorizontalScrollProps
extends Omit<FlashListProps<BaseItemDto>, "renderItem" | "data" | "style"> { extends Omit<FlashListProps<BaseItemDto>, "renderItem" | "data" | "style"> {
@@ -136,7 +137,7 @@ export function InfiniteHorizontalScroll({
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
ListEmptyComponent={ ListEmptyComponent={
<View className="flex-1 justify-center items-center"> <View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">No data available</Text> <Text className="text-center text-gray-500">{t("item_card.no_data_available")}</Text>
</View> </View>
} }
{...props} {...props}

View File

@@ -20,6 +20,7 @@ import { Button } from "../Button";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useMemo } from "react"; import { useMemo } from "react";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { t } from "i18next";
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -28,14 +29,14 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
if (processes?.length === 0) if (processes?.length === 0)
return ( return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl"> <View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Active download</Text> <Text className="text-lg font-bold">{t("home.downloads.active_download")}</Text>
<Text className="opacity-50">No active downloads</Text> <Text className="opacity-50">{t("home.downloads.no_active_downloads")}</Text>
</View> </View>
); );
return ( return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl"> <View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold mb-2">Active downloads</Text> <Text className="text-lg font-bold mb-2">{t("home.downloads.active_downloads")}</Text>
<View className="space-y-2"> <View className="space-y-2">
{processes?.map((p) => ( {processes?.map((p) => (
<DownloadCard key={p.item.Id} process={p} /> <DownloadCard key={p.item.Id} process={p} />
@@ -80,11 +81,11 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
} }
}, },
onSuccess: () => { onSuccess: () => {
toast.success("Download canceled"); toast.success(t("home.downloads.toasts.download_cancelled"));
}, },
onError: (e) => { onError: (e) => {
console.error(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) => {
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text> <Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
)} )}
{eta(process) && ( {eta(process) && (
<Text className="text-xs">ETA {eta(process)}</Text> <Text className="text-xs">{t("home.downloads.eta", {eta: eta(process)})}</Text>
)} )}
</View> </View>

View File

@@ -19,6 +19,7 @@ import { StyleSheet, TouchableOpacity, View, ViewProps } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Button } from "../Button"; import { Button } from "../Button";
import { Input } from "../common/Input"; import { Input } from "../common/Input";
import { useTranslation } from "react-i18next";
interface Props<T> extends ViewProps { interface Props<T> extends ViewProps {
open: boolean; open: boolean;
@@ -76,6 +77,7 @@ export const FilterSheet = <T,>({
}: Props<T>) => { }: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["80%"], []); const snapPoints = useMemo(() => ["80%"], []);
const { t } = useTranslation();
const [data, setData] = useState<T[]>([]); const [data, setData] = useState<T[]>([]);
const [offset, setOffset] = useState<number>(0); const [offset, setOffset] = useState<number>(0);
@@ -153,10 +155,10 @@ export const FilterSheet = <T,>({
> >
<View className="px-4 mt-2 mb-8"> <View className="px-4 mt-2 mb-8">
<Text className="font-bold text-2xl">{title}</Text> <Text className="font-bold text-2xl">{title}</Text>
<Text className="mb-2 text-neutral-500">{_data?.length} items</Text> <Text className="mb-2 text-neutral-500">{t("search.items", {count: _data?.length})}</Text>
{showSearch && ( {showSearch && (
<Input <Input
placeholder="Search..." placeholder={t("search.search")}
className="my-2" className="my-2"
value={search} value={search}
onChangeText={(text) => { onChangeText={(text) => {

View File

@@ -5,6 +5,7 @@ import { View } from "react-native";
import { ScrollingCollectionList } from "./ScrollingCollectionList"; import { ScrollingCollectionList } from "./ScrollingCollectionList";
import { useCallback } from "react"; import { useCallback } from "react";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client"; import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
import { t } from "i18next";
export const Favorites = () => { export const Favorites = () => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -60,38 +61,38 @@ export const Favorites = () => {
<ScrollingCollectionList <ScrollingCollectionList
queryFn={fetchFavoriteSeries} queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]} queryKey={["home", "favorites", "series"]}
title="Series" title={t("favorites.series")}
hideIfEmpty hideIfEmpty
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryFn={fetchFavoriteMovies} queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]} queryKey={["home", "favorites", "movies"]}
title="Movies" title={t("favorites.movies")}
hideIfEmpty hideIfEmpty
orientation="vertical" orientation="vertical"
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryFn={fetchFavoriteEpisodes} queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]} queryKey={["home", "favorites", "episodes"]}
title="Episodes" title={t("favorites.episodes")}
hideIfEmpty hideIfEmpty
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryFn={fetchFavoriteVideos} queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]} queryKey={["home", "favorites", "videos"]}
title="Videos" title={t("favorites.videos")}
hideIfEmpty hideIfEmpty
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryFn={fetchFavoriteBoxsets} queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]} queryKey={["home", "favorites", "boxsets"]}
title="Boxsets" title={t("favorites.boxsets")}
hideIfEmpty hideIfEmpty
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryFn={fetchFavoritePlaylists} queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]} queryKey={["home", "favorites", "playlists"]}
title="Playlists" title={t("favorites.playlists")}
hideIfEmpty hideIfEmpty
/> />
</View> </View>

View File

@@ -11,6 +11,7 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText"; import { ItemCardText } from "../ItemCardText";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import SeriesPoster from "../posters/SeriesPoster"; import SeriesPoster from "../posters/SeriesPoster";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps { interface Props extends ViewProps {
title?: string | null; title?: string | null;
@@ -43,6 +44,8 @@ export const ScrollingCollectionList: React.FC<Props> = ({
if (hideIfEmpty === true && data?.length === 0) return null; if (hideIfEmpty === true && data?.length === 0) return null;
const { t } = useTranslation();
return ( return (
<View {...props}> <View {...props}>
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100"> <Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
@@ -50,7 +53,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
</Text> </Text>
{isLoading === false && data?.length === 0 && ( {isLoading === false && data?.length === 0 && (
<View className="px-4"> <View className="px-4">
<Text className="text-neutral-500">No items</Text> <Text className="text-neutral-500">{t("home.no_items")}</Text>
</View> </View>
)} )}
{isLoading ? ( {isLoading ? (

View File

@@ -5,15 +5,17 @@ import React from "react";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import PersonPoster from "@/components/jellyseerr/PersonPoster"; import PersonPoster from "@/components/jellyseerr/PersonPoster";
import { useTranslation } from "react-i18next";
const CastSlide: React.FC< const CastSlide: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps { details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, ...props }) => { > = ({ details, ...props }) => {
const { t } = useTranslation();
return ( return (
details?.credits?.cast && details?.credits?.cast &&
details?.credits?.cast?.length > 0 && ( details?.credits?.cast?.length > 0 && (
<View {...props}> <View {...props}>
<Text className="text-lg font-bold mb-2 px-4">Cast</Text> <Text className="text-lg font-bold mb-2 px-4">{t("jellyseerr.cast")}</Text>
<FlashList <FlashList
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}

View File

@@ -9,6 +9,7 @@ import { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import CountryFlag from "react-native-country-flag"; import CountryFlag from "react-native-country-flag";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import { useTranslation } from "react-i18next";
interface Release { interface Release {
certification: string; certification: string;
@@ -50,6 +51,7 @@ const DetailFacts: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps { details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, className, ...props }) => { > = ({ details, className, ...props }) => {
const { jellyseerrUser } = useJellyseerr(); const { jellyseerrUser } = useJellyseerr();
const { t } = useTranslation();
const locale = useMemo(() => { const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en"; return jellyseerrUser?.settings?.locale || "en";
@@ -144,21 +146,21 @@ const DetailFacts: React.FC<
return ( return (
details && ( details && (
<View className="p-4"> <View className="p-4">
<Text className="text-lg font-bold">Details</Text> <Text className="text-lg font-bold">{t("jellyseerr.details")}</Text>
<View <View
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`} className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
{...props} {...props}
> >
<Fact title="Status" fact={details?.status} /> <Fact title={t("jellyseerr.status")} fact={details?.status} />
<Fact <Fact
title="Original Title" title={t("jellyseerr.original_title")}
fact={(details as TvDetails)?.originalName} fact={(details as TvDetails)?.originalName}
/> />
{details.keywords.some( {details.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID (keyword) => keyword.id === ANIME_KEYWORD_ID
) && <Fact title="Series Type" fact="Anime" />} ) && <Fact title={t("jellyseerr.series_type")} fact="Anime" />}
<Facts <Facts
title="Release Dates" title={t("jellyseerr.release_dates")}
facts={filteredReleases?.map?.((r: Release, idx) => ( facts={filteredReleases?.map?.((r: Release, idx) => (
<View key={idx} className="flex flex-row space-x-2 items-center"> <View key={idx} className="flex flex-row space-x-2 items-center">
{r.type === 3 ? ( {r.type === 3 ? (
@@ -184,13 +186,13 @@ const DetailFacts: React.FC<
</View> </View>
))} ))}
/> />
<Fact title="First Air Date" fact={firstAirDate} /> <Fact title={t("jellyseerr.first_air_date")} fact={firstAirDate} />
<Fact title="Next Air Date" fact={nextAirDate} /> <Fact title={t("jellyseerr.next_air_date")} fact={nextAirDate} />
<Fact title="Revenue" fact={revenue} /> <Fact title={t("jellyseerr.revenue")} fact={revenue} />
<Fact title="Budget" fact={budget} /> <Fact title={t("jellyseerr.budget")} fact={budget} />
<Fact title="Original Language" fact={spokenLanguage} /> <Fact title={t("jellyseerr.original_language")} fact={spokenLanguage} />
<Facts <Facts
title="Production Country" title={t("jellyseerr.production_country")}
facts={details?.productionCountries?.map((n, idx) => ( facts={details?.productionCountries?.map((n, idx) => (
<View key={idx} className="flex flex-row items-center space-x-2"> <View key={idx} className="flex flex-row items-center space-x-2">
<CountryFlag isoCode={n.iso_3166_1} size={10} /> <CountryFlag isoCode={n.iso_3166_1} size={10} />
@@ -199,14 +201,14 @@ const DetailFacts: React.FC<
))} ))}
/> />
<Facts <Facts
title="Studios" title={t("jellyseerr.studios")}
facts={uniqBy(details?.productionCompanies, "name")?.map( facts={uniqBy(details?.productionCompanies, "name")?.map(
(n) => n.name (n) => n.name
)} )}
/> />
<Facts title="Network" facts={networks?.map((n) => n.name)} /> <Facts title={t("jellyseerr.network")}facts={networks?.map((n) => n.name)} />
<Facts <Facts
title="Currently Streaming on" title={t("jellyseerr.currently_streaming_on")}
facts={streamingProviders?.map((s) => s.name)} facts={streamingProviders?.map((s) => s.name)}
/> />
</View> </View>

View File

@@ -20,6 +20,7 @@ import JellyseerrPoster from "../posters/JellyseerrPoster";
import { LoadingSkeleton } from "../search/LoadingSkeleton"; import { LoadingSkeleton } from "../search/LoadingSkeleton";
import { SearchItemWrapper } from "../search/SearchItemWrapper"; import { SearchItemWrapper } from "../search/SearchItemWrapper";
import PersonPoster from "./PersonPoster"; import PersonPoster from "./PersonPoster";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps { interface Props extends ViewProps {
searchQuery: string; searchQuery: string;
@@ -28,6 +29,7 @@ interface Props extends ViewProps {
export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => { export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => {
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const opacity = useSharedValue(1); const opacity = useSharedValue(1);
const { t } = useTranslation();
const { const {
data: jellyseerrDiscoverSettings, data: jellyseerrDiscoverSettings,
@@ -117,7 +119,7 @@ export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => {
!l2 && ( !l2 && (
<View> <View>
<Text className="text-center text-lg font-bold mt-4"> <Text className="text-center text-lg font-bold mt-4">
No results found for {t("search.no_results_found_for")}
</Text> </Text>
<Text className="text-xs text-purple-600 text-center"> <Text className="text-xs text-purple-600 text-center">
"{searchQuery}" "{searchQuery}"
@@ -127,21 +129,21 @@ export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => {
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}> <View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper <SearchItemWrapper
header="Request Movies" header={t("search.request_movies")}
items={jellyseerrMovieResults} items={jellyseerrMovieResults}
renderItem={(item: MovieResult) => ( renderItem={(item: MovieResult) => (
<JellyseerrPoster item={item} key={item.id} /> <JellyseerrPoster item={item} key={item.id} />
)} )}
/> />
<SearchItemWrapper <SearchItemWrapper
header="Request Series" header={t("search.request_series")}
items={jellyseerrTvResults} items={jellyseerrTvResults}
renderItem={(item: TvResult) => ( renderItem={(item: TvResult) => (
<JellyseerrPoster item={item} key={item.id} /> <JellyseerrPoster item={item} key={item.id} />
)} )}
/> />
<SearchItemWrapper <SearchItemWrapper
header="Actors" header={t("search.actors")}
items={jellyseerrPersonResults} items={jellyseerrPersonResults}
renderItem={(item: PersonResult) => ( renderItem={(item: PersonResult) => (
<PersonPoster <PersonPoster

View File

@@ -10,6 +10,7 @@ import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/request
import {BottomSheetModalMethods} from "@gorhom/bottom-sheet/lib/typescript/types"; import {BottomSheetModalMethods} from "@gorhom/bottom-sheet/lib/typescript/types";
import {Button} from "@/components/Button"; import {Button} from "@/components/Button";
import {Text} from "@/components/common/Text"; import {Text} from "@/components/common/Text";
import { useTranslation } from "react-i18next";
interface Props { interface Props {
id: number; id: number;
@@ -36,6 +37,8 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
userId: jellyseerrUser?.id userId: jellyseerrUser?.id
}); });
const { t } = useTranslation();
const [modalRequestProps, setModalRequestProps] = useState<MediaRequestBody>(); const [modalRequestProps, setModalRequestProps] = useState<MediaRequestBody>();
const {data: serviceSettings} = useQuery({ const {data: serviceSettings} = useQuery({
@@ -103,7 +106,7 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
); );
const seasonTitle = useMemo( const seasonTitle = useMemo(
() => modalRequestProps?.seasons?.length ? `Season (${modalRequestProps?.seasons})` : undefined, () => modalRequestProps?.seasons?.length ? t("jellyseerr.season_x", {seasons: modalRequestProps?.seasons}) : undefined,
[modalRequestProps?.seasons] [modalRequestProps?.seasons]
); );
@@ -148,7 +151,7 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
return <BottomSheetView> return <BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2"> <View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View> <View>
<Text className="font-bold text-2xl text-neutral-100">Advanced</Text> <Text className="font-bold text-2xl text-neutral-100">{t("jellyseerr.advanced")}</Text>
{seasonTitle && {seasonTitle &&
<Text className="text-neutral-300">{seasonTitle}</Text> <Text className="text-neutral-300">{seasonTitle}</Text>
} }
@@ -161,27 +164,27 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
titleExtractor={(item) => item.name} titleExtractor={(item) => item.name}
placeholderText={defaultProfile.name} placeholderText={defaultProfile.name}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
label={"Quality Profile"} label={t("jellyseerr.quality_profile")}
onSelected={(item) => onSelected={(item) =>
item && setRequestOverrides((prev) => ({ item && setRequestOverrides((prev) => ({
...prev, ...prev,
profileId: item?.id profileId: item?.id
})) }))
} }
title={"Quality Profile"} title={t("jellyseerr.quality_profile")}
/> />
<Dropdown <Dropdown
data={defaultServiceDetails.rootFolders} data={defaultServiceDetails.rootFolders}
titleExtractor={pathTitleExtractor} titleExtractor={pathTitleExtractor}
placeholderText={defaultFolder ? pathTitleExtractor(defaultFolder) : ""} placeholderText={defaultFolder ? pathTitleExtractor(defaultFolder) : ""}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
label={"Root Folder"} label={t("jellyseerr.root_folder")}
onSelected={(item) => onSelected={(item) =>
item && setRequestOverrides((prev) => ({ item && setRequestOverrides((prev) => ({
...prev, ...prev,
rootFolder: item.path rootFolder: item.path
}))} }))}
title={"Root Folder"} title={t("jellyseerr.root_folder")}
/> />
<Dropdown <Dropdown
multi={true} multi={true}
@@ -189,28 +192,28 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
titleExtractor={(item) => item.label} titleExtractor={(item) => item.label}
placeholderText={defaultTags.map(t => t.label).join(",")} placeholderText={defaultTags.map(t => t.label).join(",")}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
label={"Tags"} label={t("jellyseerr.tags")}
onSelected={(...item) => onSelected={(...item) =>
item && setRequestOverrides((prev) => ({ item && setRequestOverrides((prev) => ({
...prev, ...prev,
tags: item.map(i => i.id) tags: item.map(i => i.id)
})) }))
} }
title={"Tags"} title={t("jellyseerr.tags")}
/> />
<Dropdown <Dropdown
data={users} data={users}
titleExtractor={(item) => item.displayName} titleExtractor={(item) => item.displayName}
placeholderText={jellyseerrUser!!.displayName} placeholderText={jellyseerrUser!!.displayName}
keyExtractor={(item) => item.id.toString() || ""} keyExtractor={(item) => item.id.toString() || ""}
label={"Request As"} label={t("jellyseerr.request_as")}
onSelected={(item) => onSelected={(item) =>
item && setRequestOverrides((prev) => ({ item && setRequestOverrides((prev) => ({
...prev, ...prev,
userId: item?.id userId: item?.id
})) }))
} }
title={"Request As"} title={t("jellyseerr.request_as")}
/> />
</> </>
) )
@@ -221,7 +224,7 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
onPress={request} onPress={request}
color="purple" color="purple"
> >
Request {t("jellyseerr.request_button")}
</Button> </Button>
</View> </View>
</BottomSheetView> </BottomSheetView>

View File

@@ -4,6 +4,7 @@ import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import {View, ViewProps} from "react-native"; import {View, ViewProps} from "react-native";
import { t } from "i18next";
export interface SlideProps { export interface SlideProps {
slide: DiscoverSlider; slide: DiscoverSlider;
@@ -32,7 +33,7 @@ const Slide = <T extends unknown>({
return ( return (
<View {...props}> <View {...props}>
<Text className="font-bold text-lg mb-2 px-4"> <Text className="font-bold text-lg mb-2 px-4">
{DiscoverSliderType[slide.type].toString().toTitle()} {t("search." + DiscoverSliderType[slide.type].toString().toLowerCase())}
</Text> </Text>
<FlashList <FlashList
horizontal horizontal

View File

@@ -15,6 +15,7 @@ import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { TouchableOpacityProps, View } from "react-native"; import { TouchableOpacityProps, View } from "react-native";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { useTranslation } from "react-i18next";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
library: BaseItemDto; library: BaseItemDto;
@@ -42,6 +43,8 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const { t } = useTranslation();
const url = useMemo( const url = useMemo(
() => () =>
getPrimaryImageUrl({ getPrimaryImageUrl({
@@ -69,13 +72,13 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
let nameStr: string; let nameStr: string;
if (library.CollectionType === "movies") { if (library.CollectionType === "movies") {
nameStr = "movies"; nameStr = t("library.item_types.movies");
} else if (library.CollectionType === "tvshows") { } else if (library.CollectionType === "tvshows") {
nameStr = "series"; nameStr = t("library.item_types.series");
} else if (library.CollectionType === "boxsets") { } else if (library.CollectionType === "boxsets") {
nameStr = "box sets"; nameStr = t("library.item_types.boxsets");
} else { } else {
nameStr = "items"; nameStr = t("library.item_types.items");
} }
return nameStr; return nameStr;

View File

@@ -12,6 +12,7 @@ import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import Poster from "../posters/Poster"; import Poster from "../posters/Poster";
import { itemRouter } from "../common/TouchableItemRouter"; import { itemRouter } from "../common/TouchableItemRouter";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps { interface Props extends ViewProps {
item?: BaseItemDto | null; item?: BaseItemDto | null;
@@ -21,6 +22,7 @@ interface Props extends ViewProps {
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => { export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const segments = useSegments(); const segments = useSegments();
const { t } = useTranslation();
const from = segments[2]; const from = segments[2];
const destinctPeople = useMemo(() => { const destinctPeople = useMemo(() => {
@@ -40,7 +42,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
return ( return (
<View {...props} className="flex flex-col"> <View {...props} className="flex flex-col">
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text> <Text className="text-lg font-bold mb-2 px-4">{t("item_card.cast_and_crew")}</Text>
<HorizontalScroll <HorizontalScroll
loading={loading} loading={loading}
keyExtractor={(i, idx) => i.Id.toString()} keyExtractor={(i, idx) => i.Id.toString()}

View File

@@ -8,6 +8,7 @@ import Poster from "../posters/Poster";
import { HorizontalScroll } from "../common/HorrizontalScroll"; import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps { interface Props extends ViewProps {
item?: BaseItemDto | null; item?: BaseItemDto | null;
@@ -15,10 +16,11 @@ interface Props extends ViewProps {
export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => { export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const { t } = useTranslation();
return ( return (
<View {...props}> <View {...props}>
<Text className="text-lg font-bold mb-2 px-4">Series</Text> <Text className="text-lg font-bold mb-2 px-4">{t("item_card.series")}</Text>
<HorizontalScroll <HorizontalScroll
data={[item]} data={[item]}
height={247} height={247}

View File

@@ -20,6 +20,7 @@ import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Image } from "expo-image"; import { Image } from "expo-image";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { t } from "i18next";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie"; import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
@@ -173,13 +174,13 @@ const JellyseerrSeasons: React.FC<{
const promptRequestAll = useCallback( const promptRequestAll = useCallback(
() => () =>
Alert.alert("Confirm", "Are you sure you want to request all seasons?", [ Alert.alert(t("jellyseerr.confirm"), t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"), [
{ {
text: "Cancel", text: t("jellyseerr.cancel"),
style: "cancel", style: "cancel",
}, },
{ {
text: "Yes", text: t("jellyseerr.yes"),
onPress: requestAll, onPress: requestAll,
}, },
]), ]),
@@ -207,7 +208,7 @@ const JellyseerrSeasons: React.FC<{
return ( return (
<View> <View>
<View className="flex flex-row justify-between items-end px-4"> <View className="flex flex-row justify-between items-end px-4">
<Text className="text-lg font-bold mb-2">Seasons</Text> <Text className="text-lg font-bold mb-2">{t("item_card.seasons")}</Text>
{!allSeasonsAvailable && ( {!allSeasonsAvailable && (
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}> <RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
<Ionicons name="bag-add" color="white" size={26} /> <Ionicons name="bag-add" color="white" size={26} />
@@ -227,7 +228,7 @@ const JellyseerrSeasons: React.FC<{
)} )}
ListHeaderComponent={() => ( ListHeaderComponent={() => (
<View className="flex flex-row justify-between items-end px-4"> <View className="flex flex-row justify-between items-end px-4">
<Text className="text-lg font-bold mb-2">Seasons</Text> <Text className="text-lg font-bold mb-2">{t("item_card.seasons")}</Text>
{!allSeasonsAvailable && ( {!allSeasonsAvailable && (
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}> <RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
<Ionicons name="bag-add" color="white" size={26} /> <Ionicons name="bag-add" color="white" size={26} />
@@ -255,8 +256,8 @@ const JellyseerrSeasons: React.FC<{
<Tags <Tags
textClass="" textClass=""
tags={[ tags={[
`Season ${season.seasonNumber}`, t("jellyseerr.season_number", {season_number: season.seasonNumber}),
`${season.episodeCount} Episodes`, t("jellyseerr.number_episodes", {episode_number: season.episodeCount}),
]} ]}
/> />
{[0].map(() => { {[0].map(() => {

View File

@@ -12,10 +12,12 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText"; import { ItemCardText } from "../ItemCardText";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useTranslation } from "react-i18next";
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => { export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const { t } = useTranslation();
const { data: items } = useQuery({ const { data: items } = useQuery({
queryKey: ["nextUp", seriesId], queryKey: ["nextUp", seriesId],
@@ -37,14 +39,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
if (!items?.length) if (!items?.length)
return ( return (
<View className="px-4"> <View className="px-4">
<Text className="text-lg font-bold mb-2">Next up</Text> <Text className="text-lg font-bold mb-2">{t("item_card.next_up")}</Text>
<Text className="opacity-50">No items to display</Text> <Text className="opacity-50">{t("item_card.no_items_to_display")}</Text>
</View> </View>
); );
return ( return (
<View> <View>
<Text className="text-lg font-bold px-4 mb-2">Next up</Text> <Text className="text-lg font-bold px-4 mb-2">{t("item_card.next_up")}</Text>
<FlashList <FlashList
contentContainerStyle={{ paddingLeft: 16 }} contentContainerStyle={{ paddingLeft: 16 }}
horizontal horizontal

View File

@@ -3,6 +3,7 @@ import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { t } from "i18next";
type Props = { type Props = {
item: BaseItemDto; item: BaseItemDto;
@@ -91,7 +92,7 @@ export const SeasonDropdown: React.FC<Props> = ({
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-row"> <View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>Season {seasonIndex}</Text> <Text>{t("item_card.season")} {seasonIndex}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -104,7 +105,7 @@ export const SeasonDropdown: React.FC<Props> = ({
collisionPadding={8} collisionPadding={8}
sideOffset={8} sideOffset={8}
> >
<DropdownMenu.Label>Seasons</DropdownMenu.Label> <DropdownMenu.Label>{t("item_card.seasons")}</DropdownMenu.Label>
{seasons?.sort(sortByIndex).map((season: any) => ( {seasons?.sort(sortByIndex).map((season: any) => (
<DropdownMenu.Item <DropdownMenu.Item
key={season[keys.title]} key={season[keys.title]}

View File

@@ -17,7 +17,7 @@ import {
SeasonIndexState, SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { useTranslation } from "react-i18next";
type Props = { type Props = {
item: BaseItemDto; item: BaseItemDto;
initialSeasonIndex?: number; initialSeasonIndex?: number;
@@ -29,6 +29,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const { t } = useTranslation();
const seasonIndex = useMemo( const seasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""], () => seasonIndexState[item.Id ?? ""],
@@ -145,7 +146,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
/> />
{episodes?.length || 0 > 0 ? ( {episodes?.length || 0 > 0 ? (
<DownloadItems <DownloadItems
title="Download Season" title={t("item_card.download.download_season")}
className="ml-2" className="ml-2"
items={episodes || []} items={episodes || []}
MissingDownloadIconComponent={() => ( MissingDownloadIconComponent={() => (
@@ -210,7 +211,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
{(episodes?.length || 0) === 0 ? ( {(episodes?.length || 0) === 0 ? (
<View className="flex flex-col"> <View className="flex flex-col">
<Text className="text-neutral-500"> <Text className="text-neutral-500">
No episodes for this season {t("item_card.no_episodes_for_this_season")}
</Text> </Text>
</View> </View>
) : null} ) : null}

View File

@@ -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> = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const { t } = useTranslation();
if (!settings) return null;
return (
<View>
<ListGroup
title={t("home.settings.languages.title")}
>
<ListItem title={t("home.settings.languages.app_language")}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{APP_LANGUAGES.find(
(l) => l.value === settings?.preferedLanguage
)?.label || t("home.settings.languages.system")}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>
{t("home.settings.languages.title")}
</DropdownMenu.Label>
<DropdownMenu.Item
key={"unknown"}
onSelect={() => {
updateSettings({
preferedLanguage: undefined,
});
}}
>
<DropdownMenu.ItemTitle>
{t("home.settings.languages.system")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{APP_LANGUAGES?.map((l) => (
<DropdownMenu.Item
key={l?.value ?? "unknown"}
onSelect={() => {
updateSettings({
preferedLanguage: l.value,
});
}}
>
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
</ListGroup>
</View>
);
};

View File

@@ -3,6 +3,7 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { useMedia } from "./MediaContext"; import { useMedia } from "./MediaContext";
import { Switch } from "react-native-gesture-handler"; import { Switch } from "react-native-gesture-handler";
import { useTranslation } from "react-i18next";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
@@ -15,21 +16,22 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
const [_, __, pluginSettings] = useSettings(); const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media; const { settings, updateSettings } = media;
const cultures = media.cultures; const cultures = media.cultures;
const { t } = useTranslation();
if (!settings) return null; if (!settings) return null;
return ( return (
<View {...props}> <View {...props}>
<ListGroup <ListGroup
title={"Audio"} title={t("home.settings.audio.audio_title")}
description={ description={
<Text className="text-[#8E8D91] text-xs"> <Text className="text-[#8E8D91] text-xs">
Choose a default audio language. {t("home.settings.audio.audio_hint")}
</Text> </Text>
} }
> >
<ListItem <ListItem
title={"Set Audio Track From Previous Item"} title={t("home.settings.audio.set_audio_track")}
disabled={pluginSettings?.rememberAudioSelections?.locked} disabled={pluginSettings?.rememberAudioSelections?.locked}
> >
<Switch <Switch
@@ -40,12 +42,12 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
} }
/> />
</ListItem> </ListItem>
<ListItem title="Audio language"> <ListItem title={t("home.settings.audio.audio_language")}>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 "> <TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
<Text className="mr-1 text-[#8E8D91]"> <Text className="mr-1 text-[#8E8D91]">
{settings?.defaultAudioLanguage?.DisplayName || "None"} {settings?.defaultAudioLanguage?.DisplayName || t("home.settings.audio.none")}
</Text> </Text>
<Ionicons <Ionicons
name="chevron-expand-sharp" name="chevron-expand-sharp"
@@ -63,7 +65,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
collisionPadding={8} collisionPadding={8}
sideOffset={8} sideOffset={8}
> >
<DropdownMenu.Label>Languages</DropdownMenu.Label> <DropdownMenu.Label>{t("home.settings.audio.language")}</DropdownMenu.Label>
<DropdownMenu.Item <DropdownMenu.Item
key={"none-audio"} key={"none-audio"}
onSelect={() => { onSelect={() => {
@@ -72,7 +74,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
}); });
}} }}
> >
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>{t("home.settings.audio.none")}</DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
{cultures?.map((l) => ( {cultures?.map((l) => (
<DropdownMenu.Item <DropdownMenu.Item

View File

@@ -10,6 +10,7 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
export const DownloadSettings: React.FC = ({ ...props }) => { export const DownloadSettings: React.FC = ({ ...props }) => {
@@ -17,6 +18,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
const { setProcesses } = useDownload(); const { setProcesses } = useDownload();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
const allDisabled = useMemo( const allDisabled = useMemo(
() => () =>
@@ -30,9 +32,9 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
return ( return (
<DisabledSetting disabled={allDisabled} {...props} className="mb-4"> <DisabledSetting disabled={allDisabled} {...props} className="mb-4">
<ListGroup title="Downloads"> <ListGroup title={t("home.settings.downloads.downloads_title")}>
<ListItem <ListItem
title="Download method" title={t("home.settings.downloads.download_method")}
disabled={pluginSettings?.downloadMethod?.locked} disabled={pluginSettings?.downloadMethod?.locked}
> >
<DropdownMenu.Root> <DropdownMenu.Root>
@@ -40,8 +42,8 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3"> <TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]"> <Text className="mr-1 text-[#8E8D91]">
{settings.downloadMethod === DownloadMethod.Remux {settings.downloadMethod === DownloadMethod.Remux
? "Default" ? t("home.settings.downloads.default")
: "Optimized"} : t("home.settings.downloads.optimized")}
</Text> </Text>
<Ionicons <Ionicons
name="chevron-expand-sharp" name="chevron-expand-sharp"
@@ -59,7 +61,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
collisionPadding={8} collisionPadding={8}
sideOffset={8} sideOffset={8}
> >
<DropdownMenu.Label>Methods</DropdownMenu.Label> <DropdownMenu.Label>{t("home.settings.downloads.methods")}</DropdownMenu.Label>
<DropdownMenu.Item <DropdownMenu.Item
key="1" key="1"
onSelect={() => { onSelect={() => {
@@ -67,7 +69,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
setProcesses([]); setProcesses([]);
}} }}
> >
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>{t("home.settings.downloads.default")}</DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
key="2" key="2"
@@ -77,14 +79,14 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
}} }}
> >
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>{t("home.settings.downloads.optimized")}</DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
</ListItem> </ListItem>
<ListItem <ListItem
title="Remux max download" title={t("home.settings.downloads.remux_max_download")}
disabled={ disabled={
pluginSettings?.remuxConcurrentLimit?.locked || pluginSettings?.remuxConcurrentLimit?.locked ||
settings.downloadMethod !== DownloadMethod.Remux settings.downloadMethod !== DownloadMethod.Remux
@@ -104,7 +106,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
</ListItem> </ListItem>
<ListItem <ListItem
title="Auto download" title={t("home.settings.downloads.auto_download")}
disabled={ disabled={
pluginSettings?.autoDownload?.locked || pluginSettings?.autoDownload?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized settings.downloadMethod !== DownloadMethod.Optimized
@@ -127,7 +129,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
} }
onPress={() => router.push("/settings/optimized-server/page")} onPress={() => router.push("/settings/optimized-server/page")}
showArrow showArrow
title="Optimized Versions Server" title={t("home.settings.downloads.optimized_versions_server")}
></ListItem> ></ListItem>
</ListGroup> </ListGroup>
</DisabledSetting> </DisabledSetting>

View File

@@ -2,6 +2,7 @@ import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useState } from "react"; import { useState } from "react";
import { View } from "react-native"; import { View } from "react-native";
@@ -20,6 +21,8 @@ export const JellyseerrSettings = () => {
clearAllJellyseerData, clearAllJellyseerData,
} = useJellyseerr(); } = useJellyseerr();
const { t } = useTranslation();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [settings, updateSettings, pluginSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
@@ -47,7 +50,7 @@ export const JellyseerrSettings = () => {
updateSettings({ jellyseerrServerUrl }); updateSettings({ jellyseerrServerUrl });
}, },
onError: () => { onError: () => {
toast.error("Failed to login"); toast.error(t("jellyseerr.failed_to_login"));
}, },
onSettled: () => { onSettled: () => {
setJellyseerrPassword(undefined); setJellyseerrPassword(undefined);
@@ -89,53 +92,50 @@ export const JellyseerrSettings = () => {
<> <>
<ListGroup title={"Jellyseerr"}> <ListGroup title={"Jellyseerr"}>
<ListItem <ListItem
title="Total media requests" title={t("home.settings.plugins.jellyseerr.total_media_requests")}
value={jellyseerrUser?.requestCount?.toString()} value={jellyseerrUser?.requestCount?.toString()}
/> />
<ListItem <ListItem
title="Movie quota limit" title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
value={ value={
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited" jellyseerrUser?.movieQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
} }
/> />
<ListItem <ListItem
title="Movie quota days" title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
value={ value={
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited" jellyseerrUser?.movieQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
} }
/> />
<ListItem <ListItem
title="TV quota limit" title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"} value={jellyseerrUser?.tvQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
/> />
<ListItem <ListItem
title="TV quota days" title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
value={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"} value={jellyseerrUser?.tvQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
/> />
</ListGroup> </ListGroup>
<View className="p-4"> <View className="p-4">
<Button color="red" onPress={clearData}> <Button color="red" onPress={clearData}>
Reset Jellyseerr config {t("home.settings.plugins.jellyseerr.reset_jellyseerr_config_button")}
</Button> </Button>
</View> </View>
</> </>
) : ( ) : (
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900"> <View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
<Text className="text-xs text-red-600 mb-2"> <Text className="text-xs text-red-600 mb-2">
This integration is in its early stages. Expect things to change. {t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text> </Text>
<Text className="font-bold mb-1">Server URL</Text> <Text className="font-bold mb-1">{t("home.settings.plugins.jellyseerr.server_url")}</Text>
<View className="flex flex-col shrink mb-2"> <View className="flex flex-col shrink mb-2">
<Text className="text-xs text-gray-600"> <Text className="text-xs text-gray-600">
Example: http(s)://your-host.url {t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
<Text className="text-xs text-gray-600">
(add port if required)
</Text> </Text>
</View> </View>
<Input <Input
placeholder="Jellyseerr URL..." placeholder={t("home.settings.plugins.jellyseerr.server_url_placeholder")}
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl} value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
defaultValue={ defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
@@ -165,7 +165,7 @@ export const JellyseerrSettings = () => {
marginBottom: 8, marginBottom: 8,
}} }}
> >
{promptForJellyseerrPass ? "Clear" : "Save"} {promptForJellyseerrPass ? t("home.settings.plugins.jellyseerr.clear_button") : t("home.settings.plugins.jellyseerr.save_button")}
</Button> </Button>
<View <View
@@ -174,11 +174,11 @@ export const JellyseerrSettings = () => {
opacity: promptForJellyseerrPass ? 1 : 0.5, opacity: promptForJellyseerrPass ? 1 : 0.5,
}} }}
> >
<Text className="font-bold mb-2">Password</Text> <Text className="font-bold mb-2">{t("home.settings.plugins.jellyseerr.password")}</Text>
<Input <Input
autoFocus={true} autoFocus={true}
focusable={true} focusable={true}
placeholder={`Enter password for Jellyfin user ${user?.Name}`} placeholder={t("home.settings.plugins.jellyseerr.password_placeholder", {username: user?.Name})}
value={jellyseerrPassword} value={jellyseerrPassword}
keyboardType="default" keyboardType="default"
secureTextEntry={true} secureTextEntry={true}
@@ -198,7 +198,7 @@ export const JellyseerrSettings = () => {
className="h-12 mt-2" className="h-12 mt-2"
onPress={() => loginToJellyseerrMutation.mutate()} onPress={() => loginToJellyseerrMutation.mutate()}
> >
Login {t("home.settings.plugins.jellyseerr.login_button")}
</Button> </Button>
</View> </View>
</View> </View>

View File

@@ -3,12 +3,15 @@ import { ViewProps } from "react-native";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import {Stepper} from "@/components/inputs/Stepper"; import {Stepper} from "@/components/inputs/Stepper";
interface Props extends ViewProps {} interface Props extends ViewProps {}
export const MediaToggles: React.FC<Props> = ({ ...props }) => { export const MediaToggles: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
if (!settings) return null; if (!settings) return null;
@@ -25,16 +28,16 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
disabled={disabled} disabled={disabled}
{...props} {...props}
> >
<ListGroup title="Media Controls"> <ListGroup title={t("home.settings.media_controls.media_controls_title")}>
<ListItem <ListItem
title="Forward Skip Length" title={t("home.settings.media_controls.forward_skip_length")}
disabled={pluginSettings?.forwardSkipTime?.locked} disabled={pluginSettings?.forwardSkipTime?.locked}
> >
<Stepper <Stepper
value={settings.forwardSkipTime} value={settings.forwardSkipTime}
disabled={pluginSettings?.forwardSkipTime?.locked} disabled={pluginSettings?.forwardSkipTime?.locked}
step={5} step={5}
appendValue="s" appendValue={t("home.settings.media_controls.seconds_unit")}
min={0} min={0}
max={60} max={60}
onUpdate={(forwardSkipTime) => updateSettings({forwardSkipTime})} onUpdate={(forwardSkipTime) => updateSettings({forwardSkipTime})}
@@ -42,14 +45,14 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
</ListItem> </ListItem>
<ListItem <ListItem
title="Rewind Length" title={t("home.settings.media_controls.rewind_length")}
disabled={pluginSettings?.rewindSkipTime?.locked} disabled={pluginSettings?.rewindSkipTime?.locked}
> >
<Stepper <Stepper
value={settings.rewindSkipTime} value={settings.rewindSkipTime}
disabled={pluginSettings?.rewindSkipTime?.locked} disabled={pluginSettings?.rewindSkipTime?.locked}
step={5} step={5}
appendValue="s" appendValue={t("home.settings.media_controls.seconds_unit")}
min={0} min={0}
max={60} max={60}
onUpdate={(rewindSkipTime) => updateSettings({rewindSkipTime})} onUpdate={(rewindSkipTime) => updateSettings({rewindSkipTime})}

View File

@@ -1,5 +1,6 @@
import { TextInput, View, Linking } from "react-native"; import { TextInput, View, Linking } from "react-native";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { useTranslation } from "react-i18next";
interface Props { interface Props {
value: string; value: string;
@@ -14,14 +15,16 @@ export const OptimizedServerForm: React.FC<Props> = ({
Linking.openURL("https://github.com/streamyfin/optimized-versions-server"); Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
}; };
const { t } = useTranslation();
return ( return (
<View> <View>
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"> <View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
<View className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}> <View className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}>
<Text className="mr-4">URL</Text> <Text className="mr-4">{t("home.settings.downloads.url")}</Text>
<TextInput <TextInput
className="text-white" className="text-white"
placeholder="http(s)://domain.org:port" placeholder={t("home.settings.downloads.server_url_placeholder")}
value={value} value={value}
keyboardType="url" keyboardType="url"
returnKeyType="done" returnKeyType="done"
@@ -32,10 +35,9 @@ export const OptimizedServerForm: React.FC<Props> = ({
</View> </View>
</View> </View>
<Text className="px-4 text-xs text-neutral-500 mt-1"> <Text className="px-4 text-xs text-neutral-500 mt-1">
Enter the URL for the optimize server. The URL should include http or {t("home.settings.downloads.optimized_version_hint")}{" "}
https and optionally the port.{" "}
<Text className="text-blue-500" onPress={handleOpenLink}> <Text className="text-blue-500" onPress={handleOpenLink}>
Read more about the optimize server. {t("home.settings.downloads.read_more_about_optimized_server")}
</Text> </Text>
</Text> </Text>
</View> </View>

View File

@@ -15,6 +15,7 @@ import { toast } from "sonner-native";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import Dropdown from "@/components/common/Dropdown"; import Dropdown from "@/components/common/Dropdown";
@@ -22,6 +23,8 @@ export const OtherSettings: React.FC = () => {
const router = useRouter(); const router = useRouter();
const [settings, updateSettings, pluginSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const { t } = useTranslation();
/******************** /********************
* Background task * Background task
*******************/ *******************/
@@ -74,9 +77,9 @@ export const OtherSettings: React.FC = () => {
return ( return (
<DisabledSetting disabled={disabled}> <DisabledSetting disabled={disabled}>
<ListGroup title="Other" className=""> <ListGroup title={t("home.settings.other.other_title")} className="">
<ListItem <ListItem
title="Auto rotate" title={t("home.settings.other.auto_rotate")}
disabled={pluginSettings?.autoRotate?.locked} disabled={pluginSettings?.autoRotate?.locked}
> >
<Switch <Switch
@@ -87,7 +90,7 @@ export const OtherSettings: React.FC = () => {
</ListItem> </ListItem>
<ListItem <ListItem
title="Video orientation" title={t("home.settings.other.video_orientation")}
disabled={ disabled={
pluginSettings?.defaultVideoOrientation?.locked || pluginSettings?.defaultVideoOrientation?.locked ||
settings.autoRotate settings.autoRotate
@@ -104,7 +107,7 @@ export const OtherSettings: React.FC = () => {
title={ title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3"> <TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]"> <Text className="mr-1 text-[#8E8D91]">
{ScreenOrientationEnum[settings.defaultVideoOrientation]} {t(ScreenOrientationEnum[settings.defaultVideoOrientation])}
</Text> </Text>
<Ionicons <Ionicons
name="chevron-expand-sharp" name="chevron-expand-sharp"
@@ -113,7 +116,7 @@ export const OtherSettings: React.FC = () => {
/> />
</TouchableOpacity> </TouchableOpacity>
} }
label="Orientation" label={t("home.settings.other.orientation")}
onSelected={(defaultVideoOrientation) => onSelected={(defaultVideoOrientation) =>
updateSettings({ defaultVideoOrientation }) updateSettings({ defaultVideoOrientation })
} }
@@ -121,7 +124,7 @@ export const OtherSettings: React.FC = () => {
</ListItem> </ListItem>
<ListItem <ListItem
title="Safe area in controls" title={t("home.settings.other.safe_area_in_controls")}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked} disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
> >
<Switch <Switch
@@ -134,7 +137,7 @@ export const OtherSettings: React.FC = () => {
</ListItem> </ListItem>
<ListItem <ListItem
title="Show Custom Menu Links" title={t("home.settings.other.show_custom_menu_links")}
disabled={pluginSettings?.showCustomMenuLinks?.locked} disabled={pluginSettings?.showCustomMenuLinks?.locked}
onPress={() => onPress={() =>
Linking.openURL( Linking.openURL(
@@ -152,11 +155,11 @@ export const OtherSettings: React.FC = () => {
</ListItem> </ListItem>
<ListItem <ListItem
onPress={() => router.push("/settings/hide-libraries/page")} onPress={() => router.push("/settings/hide-libraries/page")}
title="Hide Libraries" title={t("home.settings.other.hide_libraries")}
showArrow showArrow
/> />
<ListItem <ListItem
title="Disable Haptic Feedback" title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked} disabled={pluginSettings?.disableHapticFeedback?.locked}
> >
<Switch <Switch

View File

@@ -4,16 +4,19 @@ import React from "react";
import { View } from "react-native"; import { View } from "react-native";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
export const PluginSettings = () => { export const PluginSettings = () => {
const [settings, updateSettings] = useSettings(); const [settings, updateSettings] = useSettings();
const router = useRouter(); const router = useRouter();
const { t } = useTranslation();
if (!settings) return null; if (!settings) return null;
return ( return (
<View> <View>
<ListGroup title="Plugins"> <ListGroup title={t("home.settings.plugins.plugins_title")} className="mb-4">
<ListItem <ListItem
onPress={() => router.push("/settings/jellyseerr/page")} onPress={() => router.push("/settings/jellyseerr/page")}
title={"Jellyseerr"} title={"Jellyseerr"}

View File

@@ -7,6 +7,7 @@ import {
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useRef, useState } from "react"; import React, { useCallback, useRef, useState } from "react";
@@ -26,6 +27,8 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const successHapticFeedback = useHaptic("success"); const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error"); const errorHapticFeedback = useHaptic("error");
const { t } = useTranslation();
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop <BottomSheetBackdrop
@@ -46,26 +49,26 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
}); });
if (res.status === 200) { if (res.status === 200) {
successHapticFeedback(); 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); setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close(); bottomSheetModalRef?.current?.close();
} else { } else {
errorHapticFeedback(); errorHapticFeedback();
Alert.alert("Error", "Invalid code"); Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
} }
} catch (e) { } catch (e) {
errorHapticFeedback(); errorHapticFeedback();
Alert.alert("Error", "Invalid code"); Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
} }
} }
}, [api, user, quickConnectCode]); }, [api, user, quickConnectCode]);
return ( return (
<View {...props}> <View {...props}>
<ListGroup title={"Quick Connect"}> <ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
<ListItem <ListItem
onPress={() => bottomSheetModalRef?.current?.present()} onPress={() => bottomSheetModalRef?.current?.present()}
title="Authorize Quick Connect" title={t("home.settings.quick_connect.authorize_button")}
textColor="blue" textColor="blue"
/> />
</ListGroup> </ListGroup>
@@ -85,7 +88,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2"> <View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View> <View>
<Text className="font-bold text-2xl text-neutral-100"> <Text className="font-bold text-2xl text-neutral-100">
Quick Connect {t("home.settings.quick_connect.quick_connect_title")}
</Text> </Text>
</View> </View>
<View className="flex flex-col space-y-2"> <View className="flex flex-col space-y-2">
@@ -93,7 +96,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
<BottomSheetTextInput <BottomSheetTextInput
style={{ color: "white" }} style={{ color: "white" }}
clearButtonMode="always" clearButtonMode="always"
placeholder="Enter the quick connect code..." placeholder={t("home.settings.quick_connect.enter_the_quick_connect_code")}
placeholderTextColor="#9CA3AF" placeholderTextColor="#9CA3AF"
value={quickConnectCode} value={quickConnectCode}
onChangeText={setQuickConnectCode} onChangeText={setQuickConnectCode}
@@ -105,7 +108,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
onPress={authorizeQuickConnect} onPress={authorizeQuickConnect}
color="purple" color="purple"
> >
Authorize {t("home.settings.quick_connect.authorize")}
</Button> </Button>
</View> </View>
</BottomSheetView> </BottomSheetView>

View File

@@ -7,9 +7,11 @@ import { View } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
export const StorageSettings = () => { export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload(); const { deleteAllFiles, appSizeUsage } = useDownload();
const { t } = useTranslation();
const successHapticFeedback = useHaptic("success"); const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error"); const errorHapticFeedback = useHaptic("error");
@@ -31,7 +33,7 @@ export const StorageSettings = () => {
successHapticFeedback(); successHapticFeedback();
} catch (e) { } catch (e) {
errorHapticFeedback(); errorHapticFeedback();
toast.error("Error deleting files"); toast.error(t("home.settings.toasts.error_deleting_files"));
} }
}; };
@@ -43,11 +45,10 @@ export const StorageSettings = () => {
<View> <View>
<View className="flex flex-col gap-y-1"> <View className="flex flex-col gap-y-1">
<View className="flex flex-row items-center justify-between"> <View className="flex flex-row items-center justify-between">
<Text className="">Storage</Text> <Text className="">{t("home.settings.storage.storage_title")}</Text>
{size && ( {size && (
<Text className="text-neutral-500"> <Text className="text-neutral-500">
{Number(size.total - size.remaining).bytesToReadable()} of{" "} {t("home.settings.storage.size_used", {used: Number(size.total - size.remaining).bytesToReadable(), total: size.total?.bytesToReadable()})}
{size.total?.bytesToReadable()} used
</Text> </Text>
)} )}
</View> </View>
@@ -78,18 +79,13 @@ export const StorageSettings = () => {
<View className="flex flex-row items-center"> <View className="flex flex-row items-center">
<View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View> <View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View>
<Text className="text-white text-xs"> <Text className="text-white text-xs">
App {calculatePercentage(size.app, size.total)}% {t("home.settings.storage.app_usage", {usedSpace: calculatePercentage(size.app, size.total)})}
</Text> </Text>
</View> </View>
<View className="flex flex-row items-center"> <View className="flex flex-row items-center">
<View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View> <View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View>
<Text className="text-white text-xs"> <Text className="text-white text-xs">
Phone{" "} {t("home.settings.storage.phone_usage", {availableSpace: calculatePercentage(size.total - size.remaining - size.app, size.total)})}
{calculatePercentage(
size.total - size.remaining - size.app,
size.total
)}
%
</Text> </Text>
</View> </View>
</> </>
@@ -100,7 +96,7 @@ export const StorageSettings = () => {
<ListItem <ListItem
textColor="red" textColor="red"
onPress={onDeleteClicked} onPress={onDeleteClicked}
title="Delete All Downloaded Files" title={t("home.settings.storage.delete_all_downloaded_files")}
/> />
</ListGroup> </ListGroup>
</View> </View>

View File

@@ -7,6 +7,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useTranslation } from "react-i18next";
import {useSettings} from "@/utils/atoms/settings"; import {useSettings} from "@/utils/atoms/settings";
import {Stepper} from "@/components/inputs/Stepper"; import {Stepper} from "@/components/inputs/Stepper";
import Dropdown from "@/components/common/Dropdown"; import Dropdown from "@/components/common/Dropdown";
@@ -18,6 +19,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const [_, __, pluginSettings] = useSettings(); const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media; const { settings, updateSettings } = media;
const cultures = media.cultures; const cultures = media.cultures;
const { t } = useTranslation();
if (!settings) return null; if (!settings) return null;
@@ -29,25 +31,33 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
SubtitlePlaybackMode.None, 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 ( return (
<View {...props}> <View {...props}>
<ListGroup <ListGroup
title={"Subtitles"} title={t("home.settings.subtitles.subtitle_title")}
description={ description={
<Text className="text-[#8E8D91] text-xs"> <Text className="text-[#8E8D91] text-xs">
Configure subtitle preferences. {t("home.settings.subtitles.subtitle_hint")}
</Text> </Text>
} }
> >
<ListItem title="Subtitle language"> <ListItem title={t("home.settings.subtitles.subtitle_language")}>
<Dropdown <Dropdown
data={[{DisplayName: "None", ThreeLetterISOLanguageName: "none-subs" },...(cultures ?? [])]} data={[{DisplayName: t("home.settings.subtitles.none"), ThreeLetterISOLanguageName: "none-subs" },...(cultures ?? [])]}
keyExtractor={(item) => item?.ThreeLetterISOLanguageName ?? "unknown"} keyExtractor={(item) => item?.ThreeLetterISOLanguageName ?? "unknown"}
titleExtractor={(item) => item?.DisplayName} titleExtractor={(item) => item?.DisplayName}
title={ title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3"> <TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]"> <Text className="mr-1 text-[#8E8D91]">
{settings?.defaultSubtitleLanguage?.DisplayName || "None"} {settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")}
</Text> </Text>
<Ionicons <Ionicons
name="chevron-expand-sharp" name="chevron-expand-sharp"
@@ -56,10 +66,10 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
/> />
</TouchableOpacity> </TouchableOpacity>
} }
label="Languages" label={t("home.settings.subtitles.language")}
onSelected={(defaultSubtitleLanguage) => onSelected={(defaultSubtitleLanguage) =>
updateSettings({ updateSettings({
defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === "None" defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === t("home.settings.subtitles.none")
? null ? null
: defaultSubtitleLanguage : defaultSubtitleLanguage
}) })
@@ -68,18 +78,18 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
</ListItem> </ListItem>
<ListItem <ListItem
title="Subtitle Mode" title={t("home.settings.subtitles.subtitle_mode")}
disabled={pluginSettings?.subtitleMode?.locked} disabled={pluginSettings?.subtitleMode?.locked}
> >
<Dropdown <Dropdown
data={subtitleModes} data={subtitleModes}
disabled={pluginSettings?.subtitleMode?.locked} disabled={pluginSettings?.subtitleMode?.locked}
keyExtractor={String} keyExtractor={String}
titleExtractor={String} titleExtractor={(item) => t(subtitleModeKeys[item]) || String(item)}
title={ title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3"> <TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]"> <Text className="mr-1 text-[#8E8D91]">
{settings?.subtitleMode || "Loading"} {t(subtitleModeKeys[settings?.subtitleMode]) || t("home.settings.subtitles.loading")}
</Text> </Text>
<Ionicons <Ionicons
name="chevron-expand-sharp" name="chevron-expand-sharp"
@@ -88,7 +98,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
/> />
</TouchableOpacity> </TouchableOpacity>
} }
label="Subtitle Mode" label={t("home.settings.subtitles.subtitle_mode")}
onSelected={(subtitleMode) => onSelected={(subtitleMode) =>
updateSettings({subtitleMode}) updateSettings({subtitleMode})
} }
@@ -96,7 +106,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
</ListItem> </ListItem>
<ListItem <ListItem
title="Set Subtitle Track From Previous Item" title={t("home.settings.subtitles.set_subtitle_track")}
disabled={pluginSettings?.rememberSubtitleSelections?.locked} disabled={pluginSettings?.rememberSubtitleSelections?.locked}
> >
<Switch <Switch
@@ -109,7 +119,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
</ListItem> </ListItem>
<ListItem <ListItem
title="Subtitle Size" title={t("home.settings.subtitles.subtitle_size")}
disabled={pluginSettings?.subtitleSize?.locked} disabled={pluginSettings?.subtitleSize?.locked}
> >
<Stepper <Stepper

View File

@@ -7,12 +7,14 @@ import { useAtom } from "jotai";
import Constants from "expo-constants"; import Constants from "expo-constants";
import Application from "expo-application"; import Application from "expo-application";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps {} interface Props extends ViewProps {}
export const UserInfo: React.FC<Props> = ({ ...props }) => { export const UserInfo: React.FC<Props> = ({ ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { t } = useTranslation();
const version = const version =
Application?.nativeApplicationVersion || Application?.nativeApplicationVersion ||
@@ -21,11 +23,11 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
return ( return (
<View {...props}> <View {...props}>
<ListGroup title={"User Info"}> <ListGroup title={t("home.settings.user_info.user_info_title")}>
<ListItem title="User" value={user?.Name} /> <ListItem title={t("home.settings.user_info.user")} value={user?.Name} />
<ListItem title="Server" value={api?.basePath} /> <ListItem title={t("home.settings.user_info.server")} value={api?.basePath} />
<ListItem title="Token" value={api?.accessToken} /> <ListItem title={t("home.settings.user_info.token")} value={api?.accessToken} />
<ListItem title="App version" value={version} /> <ListItem title={t("home.settings.user_info.app_version")} value={version} />
</ListGroup> </ListGroup>
</View> </View>
); );

View File

@@ -9,6 +9,7 @@ import Animated, {
runOnJS, runOnJS,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useTranslation } from "react-i18next";
interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps { interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps {
onFinish?: () => void; onFinish?: () => void;
@@ -63,6 +64,8 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
return null; return null;
} }
const { t } = useTranslation();
return ( return (
<TouchableOpacity <TouchableOpacity
className="w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900" className="w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900"
@@ -71,7 +74,7 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
> >
<Animated.View style={animatedStyle} /> <Animated.View style={animatedStyle} />
<View className="px-3 py-3"> <View className="px-3 py-3">
<Text className="text-center font-bold">Next Episode</Text> <Text className="text-center font-bold">{t("player.next_episode")}</Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );

View File

@@ -6,6 +6,7 @@ import React, { useEffect, useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native"; import { TouchableOpacity, View, ViewProps } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps { interface Props extends ViewProps {
playerRef: React.RefObject<VlcPlayerViewRef>; playerRef: React.RefObject<VlcPlayerViewRef>;
@@ -32,6 +33,8 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
return ( return (
<View <View
style={{ style={{
@@ -42,19 +45,19 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
}} }}
{...props} {...props}
> >
<Text className="font-bold">Playback State:</Text> <Text className="font-bold">{t("player.playback_state")}</Text>
<Text className="font-bold mt-2.5">Audio Tracks:</Text> <Text className="font-bold mt-2.5">{t("player.audio_tracks")}</Text>
{audioTracks && {audioTracks &&
audioTracks.map((track, index) => ( audioTracks.map((track, index) => (
<Text key={index}> <Text key={index}>
{track.name} (Index: {track.index}) {track.name} ({t("player.index")} {track.index})
</Text> </Text>
))} ))}
<Text className="font-bold mt-2.5">Subtitle Tracks:</Text> <Text className="font-bold mt-2.5">{t("player.subtitles_tracks")}</Text>
{subtitleTracks && {subtitleTracks &&
subtitleTracks.map((track, index) => ( subtitleTracks.map((track, index) => (
<Text key={index}> <Text key={index}>
{track.name} (Index: {track.index}) {track.name} ({t("player.index")} {track.index})
</Text> </Text>
))} ))}
<TouchableOpacity <TouchableOpacity
@@ -66,7 +69,7 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
} }
}} }}
> >
<Text className="text-white text-center">Refresh Tracks</Text> <Text className="text-white text-center">{t("player.refresh_tracks")}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );

View File

@@ -28,6 +28,7 @@ import Issue from "@/utils/jellyseerr/server/entity/Issue";
import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes"; import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
import { writeErrorLog } from "@/utils/log"; import { writeErrorLog } from "@/utils/log";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import { t } from "i18next";
import { import {
CombinedCredit, CombinedCredit,
PersonDetails, PersonDetails,
@@ -134,7 +135,7 @@ export class JellyseerrApi {
if (inRange(status, 200, 299)) { if (inRange(status, 200, 299)) {
if (data.version < "2.0.0") { if (data.version < "2.0.0") {
const error = 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); toast.error(error);
throw Error(error); throw Error(error);
} }
@@ -148,7 +149,7 @@ export class JellyseerrApi {
requiresPass: true, requiresPass: true,
}; };
} }
toast.error(`Jellyseerr test failed. Please try again.`); toast.error(t("jellyseerr.toasts.jellyseerr_test_failed"));
writeErrorLog( writeErrorLog(
`Jellyseerr returned a ${status} for url:\n` + `Jellyseerr returned a ${status} for url:\n` +
response.config.url + response.config.url +
@@ -161,7 +162,7 @@ export class JellyseerrApi {
}; };
}) })
.catch((e) => { .catch((e) => {
const msg = "Failed to test jellyseerr server url"; const msg = t("jellyseerr.toasts.failed_to_test_jellyseerr_server_url");
toast.error(msg); toast.error(msg);
console.error(msg, e); console.error(msg, e);
return { return {
@@ -322,7 +323,7 @@ export class JellyseerrApi {
const issue = response.data; const issue = response.data;
if (issue.status === IssueStatus.OPEN) { if (issue.status === IssueStatus.OPEN) {
toast.success("Issue submitted!"); toast.success(t("jellyseerr.toasts.issue_submitted"));
} }
return issue; return issue;
}); });
@@ -422,14 +423,14 @@ export const useJellyseerr = () => {
switch (mediaRequest.status) { switch (mediaRequest.status) {
case MediaRequestStatus.PENDING: case MediaRequestStatus.PENDING:
case MediaRequestStatus.APPROVED: case MediaRequestStatus.APPROVED:
toast.success(`Requested ${title}!`); toast.success(t("jellyseerr.toasts.requested_item", {item: title}));
onSuccess?.(); onSuccess?.()
break; break;
case MediaRequestStatus.DECLINED: case MediaRequestStatus.DECLINED:
toast.error(`You don't have permission to request!`); toast.error(t("jellyseerr.toasts.you_dont_have_permission_to_request"));
break; break;
case MediaRequestStatus.FAILED: case MediaRequestStatus.FAILED:
toast.error(`Something went wrong requesting media!`); toast.error(t("jellyseerr.toasts.something_went_wrong_requesting_media"));
break; break;
} }
}); });

View File

@@ -18,6 +18,7 @@ import useDownloadHelper from "@/utils/download";
import { Api } from "@jellyfin/sdk"; import { Api } from "@jellyfin/sdk";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server"; import { JobStatus } from "@/utils/optimize-server";
import { useTranslation } from "react-i18next";
const createFFmpegCommand = (url: string, output: string) => [ const createFFmpegCommand = (url: string, output: string) => [
"-y", // overwrite output files without asking "-y", // overwrite output files without asking
@@ -49,6 +50,7 @@ export const useRemuxHlsToMp4 = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
const [settings] = useSettings(); const [settings] = useSettings();
const { saveImage } = useImageStorage(); const { saveImage } = useImageStorage();
@@ -84,7 +86,7 @@ export const useRemuxHlsToMp4 = () => {
queryKey: ["downloadedItems"], queryKey: ["downloadedItems"],
}); });
saveDownloadedItemInfo(item, stat.getSize()); saveDownloadedItemInfo(item, stat.getSize());
toast.success("Download completed"); toast.success(t("home.downloads.toasts.download_completed"));
} }
setProcesses((prev) => { setProcesses((prev) => {
@@ -144,7 +146,7 @@ export const useRemuxHlsToMp4 = () => {
// First lets save any important assets we want to present to the user offline // First lets save any important assets we want to present to the user offline
await onSaveAssets(api, item); await onSaveAssets(api, item);
toast.success(`Download started for ${item.Name}`, { toast.success(t("home.downloads.toasts.download_started_for", {item: item.Name}), {
action: { action: {
label: "Go to download", label: "Go to download",
onClick: () => { onClick: () => {

View File

@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { Alert } from "react-native"; import { Alert } from "react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useWebSocketContext } from "@/providers/WebSocketProvider"; import { useWebSocketContext } from "@/providers/WebSocketProvider";
import { useTranslation } from "react-i18next";
interface UseWebSocketProps { interface UseWebSocketProps {
isPlaying: boolean; isPlaying: boolean;
@@ -18,6 +19,7 @@ export const useWebSocket = ({
}: UseWebSocketProps) => { }: UseWebSocketProps) => {
const router = useRouter(); const router = useRouter();
const { ws } = useWebSocketContext(); const { ws } = useWebSocketContext();
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
if (!ws) return; if (!ws) return;
@@ -40,7 +42,7 @@ export const useWebSocket = ({
console.log("Command ~ DisplayMessage"); console.log("Command ~ DisplayMessage");
const title = json?.Data?.Arguments?.Header; const title = json?.Data?.Arguments?.Header;
const body = json?.Data?.Arguments?.Text; const body = json?.Data?.Arguments?.Text;
Alert.alert("Message from server: " + title, body); Alert.alert(t("player.message_from_server", {message: title}), body);
} }
}; };

30
i18n.ts Normal file
View File

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

View File

@@ -54,6 +54,7 @@
"expo-keep-awake": "~13.0.2", "expo-keep-awake": "~13.0.2",
"expo-linear-gradient": "~13.0.2", "expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1", "expo-linking": "~6.3.1",
"expo-localization": "~16.0.0",
"expo-network": "~6.0.1", "expo-network": "~6.0.1",
"expo-notifications": "~0.28.19", "expo-notifications": "~0.28.19",
"expo-router": "~3.5.24", "expo-router": "~3.5.24",
@@ -67,11 +68,13 @@
"expo-web-browser": "~13.0.3", "expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2", "ffmpeg-kit-react-native": "^6.0.2",
"install": "^0.13.0", "install": "^0.13.0",
"i18next": "^24.2.0",
"jotai": "^2.10.1", "jotai": "^2.10.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"nativewind": "^2.0.11", "nativewind": "^2.0.11",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-i18next": "^15.4.0",
"react-native": "0.74.5", "react-native": "0.74.5",
"react-native-awesome-slider": "^2.5.6", "react-native-awesome-slider": "^2.5.6",
"react-native-circular-progress": "^1.4.1", "react-native-circular-progress": "^1.4.1",

View File

@@ -50,6 +50,7 @@ import useDownloadHelper from "@/utils/download";
import { FileInfo } from "expo-file-system"; import { FileInfo } from "expo-file-system";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import * as Application from "expo-application"; import * as Application from "expo-application";
import { useTranslation } from "react-i18next";
export type DownloadedItem = { export type DownloadedItem = {
item: Partial<BaseItemDto>; item: Partial<BaseItemDto>;
@@ -68,6 +69,7 @@ const DownloadContext = createContext<ReturnType<
function useDownloadProvider() { function useDownloadProvider() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
const [settings] = useSettings(); const [settings] = useSettings();
const router = useRouter(); const router = useRouter();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -139,9 +141,9 @@ function useDownloadProvider() {
if (settings.autoDownload) { if (settings.autoDownload) {
startDownload(job); startDownload(job);
} else { } else {
toast.info(`${job.item.Name} is ready to be downloaded`, { toast.info(t("home.downloads.toasts.item_is_ready_to_be_downloaded",{item: job.item.Name}), {
action: { action: {
label: "Go to downloads", label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => { onClick: () => {
router.push("/downloads"); router.push("/downloads");
toast.dismiss(); 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: { action: {
label: "Go to downloads", label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => { onClick: () => {
router.push("/downloads"); router.push("/downloads");
toast.dismiss(); toast.dismiss();
@@ -275,10 +277,10 @@ function useDownloadProvider() {
process.item, process.item,
doneHandler.bytesDownloaded 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, duration: 3000,
action: { action: {
label: "Go to downloads", label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => { onClick: () => {
router.push("/downloads"); router.push("/downloads");
toast.dismiss(); toast.dismiss();
@@ -300,7 +302,7 @@ function useDownloadProvider() {
if (error.errorCode === 404) { if (error.errorCode === 404) {
errorMsg = "File not found on server"; 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}`, { writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
error, error,
processDetails: { processDetails: {
@@ -357,9 +359,9 @@ function useDownloadProvider() {
throw new Error("Failed to start optimization job"); 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: { action: {
label: "Go to download", label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => { onClick: () => {
router.push("/downloads"); router.push("/downloads");
toast.dismiss(); toast.dismiss();
@@ -377,21 +379,21 @@ function useDownloadProvider() {
headers: error.response?.headers, headers: error.response?.headers,
}); });
toast.error( 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) { if (error.response) {
toast.error( 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) { } else if (error.request) {
toast.error("No response received from server"); t("home.downloads.toasts.no_response_received_from_server");
} else { } else {
toast.error("Error setting up the request"); toast.error("Error setting up the request");
} }
} else { } else {
console.error("Non-Axios error:", error); console.error("Non-Axios error:", error);
toast.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"] }), queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
]) ])
.then(() => .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) => { .catch((reason) => {
console.error("Failed to delete all files, folders, and jobs:", 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"));
}); });
}; };

View File

@@ -20,6 +20,7 @@ import React, {
import { Platform } from "react-native"; import { Platform } from "react-native";
import uuid from "react-native-uuid"; import uuid from "react-native-uuid";
import { getDeviceName } from "react-native-device-info"; import { getDeviceName } from "react-native-device-info";
import { useTranslation } from "react-i18next";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
@@ -50,6 +51,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined); const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
const [deviceId, setDeviceId] = useState<string | undefined>(undefined); const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const id = getOrSetDeviceId(); const id = getOrSetDeviceId();
@@ -261,22 +264,22 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
switch (error.response?.status) { switch (error.response?.status) {
case 401: case 401:
throw new Error("Invalid username or password"); throw new Error(t("login.invalid_username_or_password"));
case 403: 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: case 408:
throw new Error( 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: case 429:
throw new Error( throw new Error(
"Server received too many requests, try again later" t("login.server_received_too_many_requests_try_again_later")
); );
case 500: case 500:
throw new Error("There is a server error"); throw new Error(t("login.there_is_a_server_error"));
default: default:
throw new Error( 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")
); );
} }
} }

457
translations/en.json Normal file
View File

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

457
translations/fr.json Normal file
View File

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

30
translations/sv.json Normal file
View File

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

View File

@@ -28,16 +28,16 @@ export const ScreenOrientationEnum: Record<
ScreenOrientation.OrientationLock, ScreenOrientation.OrientationLock,
string string
> = { > = {
[ScreenOrientation.OrientationLock.DEFAULT]: "Default", [ScreenOrientation.OrientationLock.DEFAULT]: "home.settings.other.orientations.DEFAULT",
[ScreenOrientation.OrientationLock.ALL]: "All", [ScreenOrientation.OrientationLock.ALL]: "home.settings.other.orientations.ALL",
[ScreenOrientation.OrientationLock.PORTRAIT]: "Portrait", [ScreenOrientation.OrientationLock.PORTRAIT]: "home.settings.other.orientations.PORTRAIT",
[ScreenOrientation.OrientationLock.PORTRAIT_UP]: "Portrait Up", [ScreenOrientation.OrientationLock.PORTRAIT_UP]: "home.settings.other.orientations.PORTRAIT_UP",
[ScreenOrientation.OrientationLock.PORTRAIT_DOWN]: "Portrait Down", [ScreenOrientation.OrientationLock.PORTRAIT_DOWN]: "home.settings.other.orientations.PORTRAIT_DOWN",
[ScreenOrientation.OrientationLock.LANDSCAPE]: "Landscape", [ScreenOrientation.OrientationLock.LANDSCAPE]: "home.settings.other.orientations.LANDSCAPE",
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "Landscape Left", [ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "home.settings.other.orientations.LANDSCAPE_LEFT",
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: "Landscape Right", [ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: "home.settings.other.orientations.LANDSCAPE_RIGHT",
[ScreenOrientation.OrientationLock.OTHER]: "Other", [ScreenOrientation.OrientationLock.OTHER]: "home.settings.other.orientations.OTHER",
[ScreenOrientation.OrientationLock.UNKNOWN]: "Unknown", [ScreenOrientation.OrientationLock.UNKNOWN]: "home.settings.other.orientations.UNKNOWN",
}; };
export const DownloadOptions: DownloadOption[] = [ export const DownloadOptions: DownloadOption[] = [
@@ -107,6 +107,7 @@ export type Settings = {
forceLandscapeInVideoPlayer?: boolean; forceLandscapeInVideoPlayer?: boolean;
deviceProfile?: "Expo" | "Native" | "Old"; deviceProfile?: "Expo" | "Native" | "Old";
mediaListCollectionIds?: string[]; mediaListCollectionIds?: string[];
preferedLanguage?: string;
searchEngine: "Marlin" | "Jellyfin"; searchEngine: "Marlin" | "Jellyfin";
marlinServerUrl?: string; marlinServerUrl?: string;
openInVLC?: boolean; openInVLC?: boolean;
@@ -153,6 +154,7 @@ const loadSettings = (): Settings => {
forceLandscapeInVideoPlayer: false, forceLandscapeInVideoPlayer: false,
deviceProfile: "Expo", deviceProfile: "Expo",
mediaListCollectionIds: [], mediaListCollectionIds: [],
preferedLanguage: undefined,
searchEngine: "Jellyfin", searchEngine: "Jellyfin",
marlinServerUrl: "", marlinServerUrl: "",
openInVLC: false, openInVLC: false,