merge develop

This commit is contained in:
sarendsen
2025-02-05 09:44:03 +01:00
172 changed files with 16180 additions and 3607 deletions

View File

@@ -0,0 +1,77 @@
import * as DropdownMenu from "zeego/dropdown-menu";
import { Platform, 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;
// todo: fix
if (Platform.isTV) 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,43 +3,51 @@ import * as DropdownMenu from "@/components/DropdownMenu";
import { Text } from "../common/Text";
import { useMedia } from "./MediaContext";
import { Switch } from "react-native-gesture-handler";
import { useTranslation } from "react-i18next";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons";
import {useSettings} from "@/utils/atoms/settings";
interface Props extends ViewProps {}
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
const media = useMedia();
const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media;
const cultures = media.cultures;
const { t } = useTranslation();
if (!settings) return null;
return (
<View {...props}>
<ListGroup
title={"Audio"}
title={t("home.settings.audio.audio_title")}
description={
<Text className="text-[#8E8D91] text-xs">
Choose a default audio language.
{t("home.settings.audio.audio_hint")}
</Text>
}
>
<ListItem title={"Set Audio Track From Previous Item"}>
<ListItem
title={t("home.settings.audio.set_audio_track")}
disabled={pluginSettings?.rememberAudioSelections?.locked}
>
<Switch
value={settings.rememberAudioSelections}
disabled={pluginSettings?.rememberAudioSelections?.locked}
onValueChange={(value) =>
updateSettings({ rememberAudioSelections: value })
}
/>
</ListItem>
<ListItem title="Audio language">
<ListItem title={t("home.settings.audio.audio_language")}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
<Text className="mr-1 text-[#8E8D91]">
{settings?.defaultAudioLanguage?.DisplayName || "None"}
{settings?.defaultAudioLanguage?.DisplayName || t("home.settings.audio.none")}
</Text>
<Ionicons
name="chevron-expand-sharp"
@@ -57,7 +65,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Label>{t("home.settings.audio.language")}</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-audio"}
onSelect={() => {
@@ -66,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>
{cultures?.map((l) => (
<DropdownMenu.Item

View File

@@ -0,0 +1,26 @@
import {View, ViewProps} from "react-native";
import {Text} from "@/components/common/Text";
const DisabledSetting: React.FC<{disabled: boolean, showText?: boolean, text?: string} & ViewProps> = ({
disabled = false,
showText = true,
text,
children,
...props
}) => (
<View
pointerEvents={disabled ? "none" : "auto"}
style={{
opacity: disabled ? 0.5 : 1,
}}
>
<View {...props}>
{disabled && showText &&
<Text className="text-center text-red-700 my-4">{text ?? "Currently disabled by admin."}</Text>
}
{children}
</View>
</View>
)
export default DisabledSetting;

View File

@@ -1,35 +1,49 @@
import { Stepper } from "@/components/inputs/Stepper";
import { useDownload } from "@/providers/DownloadProvider";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { DownloadMethod, Settings, useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React from "react";
import { Switch, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "@/components/DropdownMenu";
import React, { useMemo } from "react";
import { Switch, TouchableOpacity } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
export const DownloadSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
export default function DownloadSettings({ ...props }) {
const [settings, updateSettings, pluginSettings] = useSettings();
const { setProcesses } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslation();
const allDisabled = useMemo(
() =>
pluginSettings?.downloadMethod?.locked === true &&
pluginSettings?.remuxConcurrentLimit?.locked === true &&
pluginSettings?.autoDownload.locked === true,
[pluginSettings]
);
if (!settings) return null;
return (
<View {...props} className="mb-4">
<ListGroup title="Downloads">
<ListItem title="Download method">
<DisabledSetting disabled={allDisabled} {...props} className="mb-4">
<ListGroup title={t("home.settings.downloads.downloads_title")}>
<ListItem
title={t("home.settings.downloads.download_method")}
disabled={pluginSettings?.downloadMethod?.locked}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings.downloadMethod === "remux"
? "Default"
: "Optimized"}
{settings.downloadMethod === DownloadMethod.Remux
? t("home.settings.downloads.default")
: t("home.settings.downloads.optimized")}
</Text>
<Ionicons
name="chevron-expand-sharp"
@@ -47,33 +61,42 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Methods</DropdownMenu.Label>
<DropdownMenu.Label>
{t("home.settings.downloads.methods")}
</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ downloadMethod: "remux" });
updateSettings({ downloadMethod: DownloadMethod.Remux });
setProcesses([]);
}}
>
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
<DropdownMenu.ItemTitle>
{t("home.settings.downloads.default")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ downloadMethod: "optimized" });
updateSettings({ downloadMethod: DownloadMethod.Optimized });
setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
<DropdownMenu.ItemTitle>
{t("home.settings.downloads.optimized")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
<ListItem
title="Remux max download"
disabled={settings.downloadMethod !== "remux"}
title={t("home.settings.downloads.remux_max_download")}
disabled={
pluginSettings?.remuxConcurrentLimit?.locked ||
settings.downloadMethod !== DownloadMethod.Remux
}
>
<Stepper
value={settings.remuxConcurrentLimit}
@@ -89,23 +112,32 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
</ListItem>
<ListItem
title="Auto download"
disabled={settings.downloadMethod !== "optimized"}
title={t("home.settings.downloads.auto_download")}
disabled={
pluginSettings?.autoDownload?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
>
<Switch
disabled={settings.downloadMethod !== "optimized"}
disabled={
pluginSettings?.autoDownload?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
value={settings.autoDownload}
onValueChange={(value) => updateSettings({ autoDownload: value })}
/>
</ListItem>
<ListItem
disabled={settings.downloadMethod !== "optimized"}
disabled={
pluginSettings?.optimizedVersionsServerUrl?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
onPress={() => router.push("/settings/optimized-server/page")}
showArrow
title="Optimized Versions Server"
title={t("home.settings.downloads.optimized_versions_server")}
></ListItem>
</ListGroup>
</View>
</DisabledSetting>
);
};
}

View File

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

View File

@@ -1,72 +1,64 @@
import React from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import React, {useMemo} from "react";
import { ViewProps } from "react-native";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Text } from "../common/Text";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
import {Stepper} from "@/components/inputs/Stepper";
interface Props extends ViewProps {}
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings();
if (!settings) return null;
const renderSkipControl = (
value: number,
onDecrease: () => void,
onIncrease: () => void
) => (
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={onDecrease}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{value}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={onIncrease}
>
<Text>+</Text>
</TouchableOpacity>
</View>
);
const disabled = useMemo(() => (
pluginSettings?.forwardSkipTime?.locked === true &&
pluginSettings?.rewindSkipTime?.locked === true
),
[pluginSettings]
)
return (
<View {...props}>
<ListGroup title="Media Controls">
<ListItem title="Forward Skip Length">
{renderSkipControl(
settings.forwardSkipTime,
() =>
updateSettings({
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
}),
() =>
updateSettings({
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
})
)}
<DisabledSetting
disabled={disabled}
{...props}
>
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
<ListItem
title={t("home.settings.media_controls.forward_skip_length")}
disabled={pluginSettings?.forwardSkipTime?.locked}
>
<Stepper
value={settings.forwardSkipTime}
disabled={pluginSettings?.forwardSkipTime?.locked}
step={5}
appendValue={t("home.settings.media_controls.seconds_unit")}
min={0}
max={60}
onUpdate={(forwardSkipTime) => updateSettings({forwardSkipTime})}
/>
</ListItem>
<ListItem title="Rewind Length">
{renderSkipControl(
settings.rewindSkipTime,
() =>
updateSettings({
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
}),
() =>
updateSettings({
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
})
)}
<ListItem
title={t("home.settings.media_controls.rewind_length")}
disabled={pluginSettings?.rewindSkipTime?.locked}
>
<Stepper
value={settings.rewindSkipTime}
disabled={pluginSettings?.rewindSkipTime?.locked}
step={5}
appendValue={t("home.settings.media_controls.seconds_unit")}
min={0}
max={60}
onUpdate={(rewindSkipTime) => updateSettings({rewindSkipTime})}
/>
</ListItem>
</ListGroup>
</View>
</DisabledSetting>
);
};

View File

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

View File

@@ -11,18 +11,22 @@ const BackgroundFetch = !Platform.isTV
: null;
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import React, { useEffect } from "react";
import { Linking, Switch, TouchableOpacity, ViewProps } from "react-native";
import { useRouter } from "expo-router";
import React, { useEffect, useMemo } from "react";
import { Linking, Switch, TouchableOpacity } from "react-native";
import { toast } from "sonner-native";
import * as DropdownMenu from "@/components/DropdownMenu";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {}
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
import Dropdown from "@/components/common/Dropdown";
export const OtherSettings: React.FC = () => {
const [settings, updateSettings] = useSettings();
const router = useRouter();
const [settings, updateSettings, pluginSettings] = useSettings();
const { t } = useTranslation();
/********************
* Background task
@@ -56,133 +60,122 @@ export const OtherSettings: React.FC = () => {
/**********************
*********************/
const disabled = useMemo(
() =>
pluginSettings?.autoRotate?.locked === true &&
pluginSettings?.defaultVideoOrientation?.locked === true &&
pluginSettings?.safeAreaInControlsEnabled?.locked === true &&
pluginSettings?.showCustomMenuLinks?.locked === true &&
pluginSettings?.hiddenLibraries?.locked === true &&
pluginSettings?.disableHapticFeedback?.locked === true,
[pluginSettings]
);
const orientations = [
ScreenOrientation.OrientationLock.DEFAULT,
ScreenOrientation.OrientationLock.PORTRAIT_UP,
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
];
if (!settings) return null;
return (
<ListGroup title="Other" className="mb-4">
<ListItem title="Auto rotate">
<Switch
value={settings.autoRotate}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</ListItem>
<DisabledSetting disabled={disabled}>
<ListGroup title={t("home.settings.other.other_title")} className="">
<ListItem
title={t("home.settings.other.auto_rotate")}
disabled={pluginSettings?.autoRotate?.locked}
>
<Switch
value={settings.autoRotate}
disabled={pluginSettings?.autoRotate?.locked}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</ListItem>
<ListItem title="Video orientation" disabled={settings.autoRotate}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
</Text>
<Ionicons name="chevron-expand-sharp" size={18} color="#5A5960" />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.DEFAULT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.DEFAULT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.PORTRAIT_UP,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.PORTRAIT_UP
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="4"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
<ListItem title="Safe area in controls">
<Switch
value={settings.safeAreaInControlsEnabled}
onValueChange={(value) =>
updateSettings({ safeAreaInControlsEnabled: value })
<ListItem
title={t("home.settings.other.video_orientation")}
disabled={
pluginSettings?.defaultVideoOrientation?.locked ||
settings.autoRotate
}
/>
</ListItem>
>
<Dropdown
data={orientations}
disabled={
pluginSettings?.defaultVideoOrientation?.locked ||
settings.autoRotate
}
keyExtractor={String}
titleExtractor={(item) => ScreenOrientationEnum[item]}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{t(ScreenOrientationEnum[settings.defaultVideoOrientation])}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
}
label={t("home.settings.other.orientation")}
onSelected={(defaultVideoOrientation) =>
updateSettings({ defaultVideoOrientation })
}
/>
</ListItem>
<ListItem
title="Show Custom Menu Links"
onPress={() =>
Linking.openURL(
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
)
}
>
<Switch
value={settings.showCustomMenuLinks}
onValueChange={(value) =>
updateSettings({ showCustomMenuLinks: value })
<ListItem
title={t("home.settings.other.safe_area_in_controls")}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
>
<Switch
value={settings.safeAreaInControlsEnabled}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
onValueChange={(value) =>
updateSettings({ safeAreaInControlsEnabled: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.other.show_custom_menu_links")}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onPress={() =>
Linking.openURL(
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
)
}
>
<Switch
value={settings.showCustomMenuLinks}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onValueChange={(value) =>
updateSettings({ showCustomMenuLinks: value })
}
/>
</ListItem>
<ListItem
onPress={() => router.push("/settings/hide-libraries/page")}
title={t("home.settings.other.hide_libraries")}
showArrow
/>
</ListItem>
</ListGroup>
<ListItem
title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked}
>
<Switch
value={settings.disableHapticFeedback}
disabled={pluginSettings?.disableHapticFeedback?.locked}
onValueChange={(disableHapticFeedback) =>
updateSettings({ disableHapticFeedback })
}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);
};

View File

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

View File

@@ -7,7 +7,8 @@ import {
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "@/packages/expo-haptics";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
import { useAtom } from "jotai";
import React, { useCallback, useRef, useState } from "react";
import { Alert, View, ViewProps } from "react-native";
@@ -23,6 +24,10 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const [user] = useAtom(userAtom);
const [quickConnectCode, setQuickConnectCode] = useState<string>();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const { t } = useTranslation();
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -43,27 +48,36 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
userId: user?.Id,
});
if (res.status === 200) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert("Success", "Quick connect authorized");
successHapticFeedback();
Alert.alert(
t("home.settings.quick_connect.success"),
t("home.settings.quick_connect.quick_connect_autorized")
);
setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close();
} else {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Invalid code");
errorHapticFeedback();
Alert.alert(
t("home.settings.quick_connect.error"),
t("home.settings.quick_connect.invalid_code")
);
}
} catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Invalid code");
errorHapticFeedback();
Alert.alert(
t("home.settings.quick_connect.error"),
t("home.settings.quick_connect.invalid_code")
);
}
}
}, [api, user, quickConnectCode]);
return (
<View {...props}>
<ListGroup title={"Quick Connect"}>
<ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
<ListItem
onPress={() => bottomSheetModalRef?.current?.present()}
title="Authorize Quick Connect"
title={t("home.settings.quick_connect.authorize_button")}
textColor="blue"
/>
</ListGroup>
@@ -83,7 +97,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">
Quick Connect
{t("home.settings.quick_connect.quick_connect_title")}
</Text>
</View>
<View className="flex flex-col space-y-2">
@@ -91,7 +105,9 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
<BottomSheetTextInput
style={{ color: "white" }}
clearButtonMode="always"
placeholder="Enter the quick connect code..."
placeholder={t(
"home.settings.quick_connect.enter_the_quick_connect_code"
)}
placeholderTextColor="#9CA3AF"
value={quickConnectCode}
onChangeText={setQuickConnectCode}
@@ -103,7 +119,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
onPress={authorizeQuickConnect}
color="purple"
>
Authorize
{t("home.settings.quick_connect.authorize")}
</Button>
</View>
</BottomSheetView>

View File

@@ -1,18 +1,19 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider";
import { clearLogs } from "@/utils/log";
import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import * as Haptics from "@/packages/expo-haptics";
import { View } from "react-native";
import * as Progress from "react-native-progress";
import { toast } from "sonner-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
const { t } = useTranslation();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const { data: size, isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize", appSizeUsage],
@@ -29,10 +30,10 @@ export const StorageSettings = () => {
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
successHapticFeedback();
} catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
toast.error("Error deleting files");
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
};
@@ -44,11 +45,13 @@ export const StorageSettings = () => {
<View>
<View className="flex flex-col gap-y-1">
<View className="flex flex-row items-center justify-between">
<Text className="">Storage</Text>
<Text className="">{t("home.settings.storage.storage_title")}</Text>
{size && (
<Text className="text-neutral-500">
{Number(size.total - size.remaining).bytesToReadable()} of{" "}
{size.total?.bytesToReadable()} used
{t("home.settings.storage.size_used", {
used: Number(size.total - size.remaining).bytesToReadable(),
total: size.total?.bytesToReadable(),
})}
</Text>
)}
</View>
@@ -79,18 +82,20 @@ export const StorageSettings = () => {
<View className="flex flex-row items-center">
<View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View>
<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>
</View>
<View className="flex flex-row items-center">
<View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View>
<Text className="text-white text-xs">
Phone{" "}
{calculatePercentage(
size.total - size.remaining - size.app,
size.total
)}
%
{t("home.settings.storage.device_usage", {
availableSpace: calculatePercentage(
size.total - size.remaining - size.app,
size.total
),
})}
</Text>
</View>
</>
@@ -101,7 +106,7 @@ export const StorageSettings = () => {
<ListItem
textColor="red"
onPress={onDeleteClicked}
title="Delete All Downloaded Files"
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
</View>

View File

@@ -7,13 +7,19 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useTranslation } from "react-i18next";
import {useSettings} from "@/utils/atoms/settings";
import {Stepper} from "@/components/inputs/Stepper";
import Dropdown from "@/components/common/Dropdown";
interface Props extends ViewProps {}
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const media = useMedia();
const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media;
const cultures = media.cultures;
const { t } = useTranslation();
if (!settings) return null;
@@ -25,22 +31,33 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
SubtitlePlaybackMode.None,
];
const subtitleModeKeys = {
[SubtitlePlaybackMode.Default]: "home.settings.subtitles.modes.Default",
[SubtitlePlaybackMode.Smart]: "home.settings.subtitles.modes.Smart",
[SubtitlePlaybackMode.OnlyForced]: "home.settings.subtitles.modes.OnlyForced",
[SubtitlePlaybackMode.Always]: "home.settings.subtitles.modes.Always",
[SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None",
};
return (
<View {...props}>
<ListGroup
title={"Subtitles"}
title={t("home.settings.subtitles.subtitle_title")}
description={
<Text className="text-[#8E8D91] text-xs">
Configure subtitle preferences.
{t("home.settings.subtitles.subtitle_hint")}
</Text>
}
>
<ListItem title="Subtitle language">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
<Dropdown
data={[{DisplayName: t("home.settings.subtitles.none"), ThreeLetterISOLanguageName: "none-subs" },...(cultures ?? [])]}
keyExtractor={(item) => item?.ThreeLetterISOLanguageName ?? "unknown"}
titleExtractor={(item) => item?.DisplayName}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
{settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")}
</Text>
<Ionicons
name="chevron-expand-sharp"
@@ -48,51 +65,31 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
color="#5A5960"
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-subs"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{cultures?.map((l) => (
<DropdownMenu.Item
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>
{l.DisplayName}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
}
label={t("home.settings.subtitles.language")}
onSelected={(defaultSubtitleLanguage) =>
updateSettings({
defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === t("home.settings.subtitles.none")
? null
: defaultSubtitleLanguage
})
}
/>
</ListItem>
<ListItem title="Subtitle Mode">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<ListItem
title={t("home.settings.subtitles.subtitle_mode")}
disabled={pluginSettings?.subtitleMode?.locked}
>
<Dropdown
data={subtitleModes}
disabled={pluginSettings?.subtitleMode?.locked}
keyExtractor={String}
titleExtractor={(item) => t(subtitleModeKeys[item]) || String(item)}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings?.subtitleMode || "Loading"}
{t(subtitleModeKeys[settings?.subtitleMode]) || t("home.settings.subtitles.loading")}
</Text>
<Ionicons
name="chevron-expand-sharp"
@@ -100,68 +97,39 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
color="#5A5960"
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitle Mode</DropdownMenu.Label>
{subtitleModes?.map((l) => (
<DropdownMenu.Item
key={l}
onSelect={() => {
updateSettings({
subtitleMode: l,
});
}}
>
<DropdownMenu.ItemTitle>{l}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
}
label={t("home.settings.subtitles.subtitle_mode")}
onSelected={(subtitleMode) =>
updateSettings({subtitleMode})
}
/>
</ListItem>
<ListItem title="Set Subtitle Track From Previous Item">
<ListItem
title={t("home.settings.subtitles.set_subtitle_track")}
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
>
<Switch
value={settings.rememberSubtitleSelections}
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
onValueChange={(value) =>
updateSettings({ rememberSubtitleSelections: value })
}
/>
</ListItem>
<ListItem title="Subtitle Size">
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
subtitleSize: Math.max(0, settings.subtitleSize - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 px-3 py-2 flex items-center justify-center">
{settings.subtitleSize}
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
subtitleSize: Math.min(120, settings.subtitleSize + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
<ListItem
title={t("home.settings.subtitles.subtitle_size")}
disabled={pluginSettings?.subtitleSize?.locked}
>
<Stepper
value={settings.subtitleSize}
disabled={pluginSettings?.subtitleSize?.locked}
step={5}
min={0}
max={120}
onUpdate={(subtitleSize) => updateSettings({subtitleSize})}
/>
</ListItem>
</ListGroup>
</View>

View File

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