mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-18 18:24:18 +01:00
Merge remote-tracking branch 'origin/develop' into feat/android/choose-download-location
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, Switch } from "react-native";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
@@ -42,11 +42,13 @@ export const AppearanceSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
|
||||
<ListItem
|
||||
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
||||
>
|
||||
<Switch
|
||||
value={settings.showLargeHomeCarousel}
|
||||
value={settings.mergeNextUpAndContinueWatching}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ showLargeHomeCarousel: value })
|
||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
@@ -57,6 +59,16 @@ export const AppearanceSettings: React.FC = () => {
|
||||
title={t("home.settings.other.hide_libraries")}
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.appearance.hide_remote_session_button")}
|
||||
>
|
||||
<Switch
|
||||
value={settings.hideRemoteSessionButton}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ hideRemoteSessionButton: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
@@ -54,6 +54,70 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
];
|
||||
}, [cultures, settings?.defaultAudioLanguage, t, updateSettings]);
|
||||
|
||||
const audioTranscodeModeLabels: Record<AudioTranscodeMode, string> = {
|
||||
[AudioTranscodeMode.Auto]: t("home.settings.audio.transcode_mode.auto"),
|
||||
[AudioTranscodeMode.ForceStereo]: t(
|
||||
"home.settings.audio.transcode_mode.stereo",
|
||||
),
|
||||
[AudioTranscodeMode.Allow51]: t("home.settings.audio.transcode_mode.5_1"),
|
||||
[AudioTranscodeMode.AllowAll]: t(
|
||||
"home.settings.audio.transcode_mode.passthrough",
|
||||
),
|
||||
};
|
||||
|
||||
const audioTranscodeModeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: [
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("home.settings.audio.transcode_mode.auto"),
|
||||
value: AudioTranscodeMode.Auto,
|
||||
selected:
|
||||
settings?.audioTranscodeMode === AudioTranscodeMode.Auto ||
|
||||
!settings?.audioTranscodeMode,
|
||||
onPress: () =>
|
||||
updateSettings({ audioTranscodeMode: AudioTranscodeMode.Auto }),
|
||||
},
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("home.settings.audio.transcode_mode.stereo"),
|
||||
value: AudioTranscodeMode.ForceStereo,
|
||||
selected:
|
||||
settings?.audioTranscodeMode === AudioTranscodeMode.ForceStereo,
|
||||
onPress: () =>
|
||||
updateSettings({
|
||||
audioTranscodeMode: AudioTranscodeMode.ForceStereo,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("home.settings.audio.transcode_mode.5_1"),
|
||||
value: AudioTranscodeMode.Allow51,
|
||||
selected:
|
||||
settings?.audioTranscodeMode === AudioTranscodeMode.Allow51,
|
||||
onPress: () =>
|
||||
updateSettings({
|
||||
audioTranscodeMode: AudioTranscodeMode.Allow51,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("home.settings.audio.transcode_mode.passthrough"),
|
||||
value: AudioTranscodeMode.AllowAll,
|
||||
selected:
|
||||
settings?.audioTranscodeMode === AudioTranscodeMode.AllowAll,
|
||||
onPress: () =>
|
||||
updateSettings({
|
||||
audioTranscodeMode: AudioTranscodeMode.AllowAll,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[settings?.audioTranscodeMode, t, updateSettings],
|
||||
);
|
||||
|
||||
if (isTv) return null;
|
||||
if (!settings) return null;
|
||||
|
||||
@@ -98,6 +162,31 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
title={t("home.settings.audio.language")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.audio.transcode_mode.title")}
|
||||
subtitle={t("home.settings.audio.transcode_mode.description")}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={audioTranscodeModeOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{
|
||||
audioTranscodeModeLabels[
|
||||
settings?.audioTranscodeMode || AudioTranscodeMode.Auto
|
||||
]
|
||||
}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.audio.transcode_mode.title")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { settings } = useSettings();
|
||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!settings) return null;
|
||||
return (
|
||||
<View>
|
||||
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
|
||||
<ListItem
|
||||
className={sessions.length !== 0 ? "bg-purple-900" : ""}
|
||||
onPress={() => router.push("/settings/dashboard/sessions")}
|
||||
title={t("home.settings.dashboard.sessions_title")}
|
||||
showArrow
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -11,12 +11,12 @@ const DisabledSetting: React.FC<
|
||||
}}
|
||||
>
|
||||
<View {...props}>
|
||||
{children}
|
||||
{disabled && showText && (
|
||||
<Text className='text-center text-red-700 my-4'>
|
||||
{text ?? "Currently disabled by admin."}
|
||||
<Text className='text-xs text-red-600 px-4 mt-1'>
|
||||
{text ?? "Disabled by admin"}
|
||||
</Text>
|
||||
)}
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function DownloadSettings() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function DownloadSettings() {
|
||||
return null;
|
||||
}
|
||||
@@ -19,7 +19,9 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
() =>
|
||||
pluginSettings?.enableHorizontalSwipeSkip?.locked === true &&
|
||||
pluginSettings?.enableLeftSideBrightnessSwipe?.locked === true &&
|
||||
pluginSettings?.enableRightSideVolumeSwipe?.locked === true,
|
||||
pluginSettings?.enableRightSideVolumeSwipe?.locked === true &&
|
||||
pluginSettings?.hideVolumeSlider?.locked === true &&
|
||||
pluginSettings?.hideBrightnessSlider?.locked === true,
|
||||
[pluginSettings],
|
||||
);
|
||||
|
||||
@@ -77,6 +79,38 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.gesture_controls.hide_volume_slider")}
|
||||
subtitle={t(
|
||||
"home.settings.gesture_controls.hide_volume_slider_description",
|
||||
)}
|
||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.hideVolumeSlider}
|
||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||
onValueChange={(hideVolumeSlider) =>
|
||||
updateSettings({ hideVolumeSlider })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.gesture_controls.hide_brightness_slider")}
|
||||
subtitle={t(
|
||||
"home.settings.gesture_controls.hide_brightness_slider_description",
|
||||
)}
|
||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.hideBrightnessSlider}
|
||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||
onValueChange={(hideBrightnessSlider) =>
|
||||
updateSettings({ hideBrightnessSlider })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -115,9 +115,6 @@ export const JellyseerrSettings = () => {
|
||||
</>
|
||||
) : (
|
||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||
<Text className='text-xs text-red-600 mb-2'>
|
||||
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||
</Text>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||
</Text>
|
||||
|
||||
33
components/settings/KefinTweaks.tsx
Normal file
33
components/settings/KefinTweaks.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, Text, View } from "react-native";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export const KefinTweaksSettings = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isEnabled = settings?.useKefinTweaks ?? false;
|
||||
|
||||
return (
|
||||
<View className=''>
|
||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||
<Text className='text-xs text-red-600 mb-2'>
|
||||
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
||||
</Text>
|
||||
|
||||
<View className='flex flex-row items-center justify-between mt-2'>
|
||||
<Text className='text-white'>
|
||||
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
|
||||
</Text>
|
||||
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||
trackColor={{ false: "#555", true: "purple" }}
|
||||
thumbColor={isEnabled ? "#fff" : "#ccc"}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
|
||||
/>
|
||||
</OptionGroup>
|
||||
|
||||
<OptionGroup title='Options'>
|
||||
<OptionGroup title={t("library.options.options_title")}>
|
||||
<ToggleItem
|
||||
label={t("library.options.show_titles")}
|
||||
value={settings.showTitles}
|
||||
|
||||
224
components/settings/LocalNetworkSettings.tsx
Normal file
224
components/settings/LocalNetworkSettings.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, TouchableOpacity, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
||||
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import {
|
||||
getServerLocalConfig,
|
||||
type LocalNetworkConfig,
|
||||
updateServerLocalConfig,
|
||||
} from "@/utils/secureCredentials";
|
||||
import { Button } from "../Button";
|
||||
import { Input } from "../common/Input";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
const DEFAULT_CONFIG: LocalNetworkConfig = {
|
||||
localUrl: "",
|
||||
homeWifiSSIDs: [],
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
interface StatusDisplayProps {
|
||||
currentSSID: string | null;
|
||||
isUsingLocalUrl: boolean;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
function StatusDisplay({
|
||||
currentSSID,
|
||||
isUsingLocalUrl,
|
||||
t,
|
||||
}: StatusDisplayProps): React.ReactElement {
|
||||
const wifiStatus = currentSSID ?? t("home.settings.network.not_connected");
|
||||
const urlType = isUsingLocalUrl
|
||||
? t("home.settings.network.local")
|
||||
: t("home.settings.network.remote");
|
||||
const urlTypeColor = isUsingLocalUrl ? "text-green-500" : "text-blue-500";
|
||||
|
||||
return (
|
||||
<View className='px-4 py-2 bg-neutral-900 rounded-xl mt-4'>
|
||||
<View className='flex-row justify-between items-center py-1'>
|
||||
<Text className='text-neutral-400'>
|
||||
{t("home.settings.network.current_wifi")}
|
||||
</Text>
|
||||
<Text>{wifiStatus}</Text>
|
||||
</View>
|
||||
<View className='flex-row justify-between items-center py-1'>
|
||||
<Text className='text-neutral-400'>
|
||||
{t("home.settings.network.using_url")}
|
||||
</Text>
|
||||
<Text className={urlTypeColor}>{urlType}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocalNetworkSettings(): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const { permissionStatus, requestPermission } = useWifiSSID();
|
||||
const { isUsingLocalUrl, currentSSID, refreshUrlState } = useServerUrl();
|
||||
|
||||
const remoteUrl = storage.getString("serverUrl");
|
||||
const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
|
||||
|
||||
useEffect(() => {
|
||||
if (remoteUrl) {
|
||||
const existingConfig = getServerLocalConfig(remoteUrl);
|
||||
if (existingConfig) {
|
||||
setConfig(existingConfig);
|
||||
}
|
||||
}
|
||||
}, [remoteUrl]);
|
||||
|
||||
const saveConfig = useCallback(
|
||||
(newConfig: LocalNetworkConfig) => {
|
||||
if (!remoteUrl) return;
|
||||
setConfig(newConfig);
|
||||
updateServerLocalConfig(remoteUrl, newConfig);
|
||||
// Trigger URL re-evaluation after config change
|
||||
refreshUrlState();
|
||||
},
|
||||
[remoteUrl, refreshUrlState],
|
||||
);
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
if (enabled && permissionStatus !== "granted") {
|
||||
const granted = await requestPermission();
|
||||
if (!granted) {
|
||||
toast.error(t("home.settings.network.permission_denied"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
saveConfig({ ...config, enabled });
|
||||
},
|
||||
[config, permissionStatus, requestPermission, saveConfig, t],
|
||||
);
|
||||
|
||||
const handleLocalUrlChange = useCallback(
|
||||
(localUrl: string) => {
|
||||
saveConfig({ ...config, localUrl });
|
||||
},
|
||||
[config, saveConfig],
|
||||
);
|
||||
|
||||
const handleAddCurrentNetwork = useCallback(() => {
|
||||
if (!currentSSID) {
|
||||
toast.error(t("home.settings.network.no_wifi_connected"));
|
||||
return;
|
||||
}
|
||||
if (config.homeWifiSSIDs.includes(currentSSID)) {
|
||||
toast.info(t("home.settings.network.network_already_added"));
|
||||
return;
|
||||
}
|
||||
saveConfig({
|
||||
...config,
|
||||
homeWifiSSIDs: [...config.homeWifiSSIDs, currentSSID],
|
||||
});
|
||||
toast.success(t("home.settings.network.network_added"));
|
||||
}, [config, currentSSID, saveConfig, t]);
|
||||
|
||||
const handleRemoveNetwork = useCallback(
|
||||
(ssidToRemove: string) => {
|
||||
saveConfig({
|
||||
...config,
|
||||
homeWifiSSIDs: config.homeWifiSSIDs.filter((s) => s !== ssidToRemove),
|
||||
});
|
||||
},
|
||||
[config, saveConfig],
|
||||
);
|
||||
|
||||
if (!remoteUrl) return null;
|
||||
|
||||
const addNetworkButtonText = currentSSID
|
||||
? t("home.settings.network.add_current_network", { ssid: currentSSID })
|
||||
: t("home.settings.network.not_connected_to_wifi");
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ListGroup title={t("home.settings.network.local_network")}>
|
||||
<ListItem
|
||||
title={t("home.settings.network.auto_switch_enabled")}
|
||||
subtitle={t("home.settings.network.auto_switch_description")}
|
||||
>
|
||||
<Switch value={config.enabled} onValueChange={handleToggleEnabled} />
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
{config.enabled && (
|
||||
<View className='pt-4'>
|
||||
<ListGroup
|
||||
title={t("home.settings.network.local_url")}
|
||||
description={
|
||||
<Text className='text-[#8E8D91] text-xs'>
|
||||
{t("home.settings.network.local_url_hint")}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<View className=''>
|
||||
<Input
|
||||
placeholder={t("home.settings.network.local_url_placeholder")}
|
||||
value={config.localUrl}
|
||||
onChangeText={handleLocalUrlChange}
|
||||
keyboardType='url'
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
</ListGroup>
|
||||
|
||||
<ListGroup
|
||||
title={t("home.settings.network.home_wifi_networks")}
|
||||
className='mt-4'
|
||||
>
|
||||
{config.homeWifiSSIDs.map((wifiSSID) => (
|
||||
<ListItem key={wifiSSID} title={wifiSSID}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleRemoveNetwork(wifiSSID)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name='close-circle' size={22} color='#EF4444' />
|
||||
</TouchableOpacity>
|
||||
</ListItem>
|
||||
))}
|
||||
{config.homeWifiSSIDs.length === 0 && (
|
||||
<ListItem
|
||||
title={t("home.settings.network.no_networks_configured")}
|
||||
subtitle={t("home.settings.network.add_network_hint")}
|
||||
/>
|
||||
)}
|
||||
</ListGroup>
|
||||
|
||||
<View className='py-2'>
|
||||
<Button
|
||||
onPress={handleAddCurrentNetwork}
|
||||
disabled={!currentSSID || permissionStatus !== "granted"}
|
||||
>
|
||||
{addNetworkButtonText}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<StatusDisplay
|
||||
currentSSID={currentSSID}
|
||||
isUsingLocalUrl={isUsingLocalUrl}
|
||||
t={t}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{permissionStatus === "denied" && (
|
||||
<View className='py-2'>
|
||||
<Text className='text-xs text-red-500'>
|
||||
{t("home.settings.network.permission_denied_explanation")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,10 @@ import type {
|
||||
UserDto,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getLocalizationApi, getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { createContext, type ReactNode, useContext, useEffect } from "react";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
@@ -30,7 +31,7 @@ export const useMedia = () => {
|
||||
export const MediaProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
const updateSetingsWrapper = (update: Partial<Settings>) => {
|
||||
const updateUserConfiguration = async (
|
||||
|
||||
100
components/settings/MpvBufferSettings.tsx
Normal file
100
components/settings/MpvBufferSettings.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
|
||||
{ key: "home.settings.buffer.cache_auto", value: "auto" },
|
||||
{ key: "home.settings.buffer.cache_yes", value: "yes" },
|
||||
{ key: "home.settings.buffer.cache_no", value: "no" },
|
||||
];
|
||||
|
||||
export const MpvBufferSettings: React.FC = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const cacheModeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: CACHE_MODE_OPTIONS.map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: t(option.key),
|
||||
value: option.value,
|
||||
selected: option.value === (settings?.mpvCacheEnabled ?? "auto"),
|
||||
onPress: () => updateSettings({ mpvCacheEnabled: option.value }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.mpvCacheEnabled, t, updateSettings],
|
||||
);
|
||||
|
||||
const currentCacheModeLabel = useMemo(() => {
|
||||
const option = CACHE_MODE_OPTIONS.find(
|
||||
(o) => o.value === (settings?.mpvCacheEnabled ?? "auto"),
|
||||
);
|
||||
return option ? t(option.key) : t("home.settings.buffer.cache_auto");
|
||||
}, [settings?.mpvCacheEnabled, t]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<ListGroup title={t("home.settings.buffer.title")} className='mb-4'>
|
||||
<ListItem title={t("home.settings.buffer.cache_mode")}>
|
||||
<PlatformDropdown
|
||||
groups={cacheModeOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{currentCacheModeLabel}
|
||||
</Text>
|
||||
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.buffer.cache_mode")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={t("home.settings.buffer.buffer_duration")}>
|
||||
<Stepper
|
||||
value={settings.mpvCacheSeconds ?? 10}
|
||||
step={5}
|
||||
min={5}
|
||||
max={120}
|
||||
onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })}
|
||||
appendValue='s'
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={t("home.settings.buffer.max_cache_size")}>
|
||||
<Stepper
|
||||
value={settings.mpvDemuxerMaxBytes ?? 150}
|
||||
step={25}
|
||||
min={50}
|
||||
max={500}
|
||||
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })}
|
||||
appendValue=' MB'
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={t("home.settings.buffer.max_backward_cache")}>
|
||||
<Stepper
|
||||
value={settings.mpvDemuxerMaxBackBytes ?? 50}
|
||||
step={25}
|
||||
min={25}
|
||||
max={200}
|
||||
onUpdate={(value) =>
|
||||
updateSettings({ mpvDemuxerMaxBackBytes: value })
|
||||
}
|
||||
appendValue=' MB'
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
150
components/settings/MpvSubtitleSettings.tsx
Normal file
150
components/settings/MpvSubtitleSettings.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, Switch, View, type ViewProps } from "react-native";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { PlatformDropdown } from "../PlatformDropdown";
|
||||
import { useMedia } from "./MediaContext";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
type AlignX = "left" | "center" | "right";
|
||||
type AlignY = "top" | "center" | "bottom";
|
||||
|
||||
export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
const isTv = Platform.isTV;
|
||||
const media = useMedia();
|
||||
const { settings, updateSettings } = media;
|
||||
|
||||
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
||||
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
||||
|
||||
const alignXLabels: Record<AlignX, string> = {
|
||||
left: "Left",
|
||||
center: "Center",
|
||||
right: "Right",
|
||||
};
|
||||
|
||||
const alignYLabels: Record<AlignY, string> = {
|
||||
top: "Top",
|
||||
center: "Center",
|
||||
bottom: "Bottom",
|
||||
};
|
||||
|
||||
const alignXOptionGroups = useMemo(() => {
|
||||
const options = alignXOptions.map((align) => ({
|
||||
type: "radio" as const,
|
||||
label: alignXLabels[align],
|
||||
value: align,
|
||||
selected: align === (settings?.mpvSubtitleAlignX ?? "center"),
|
||||
onPress: () => updateSettings({ mpvSubtitleAlignX: align }),
|
||||
}));
|
||||
return [{ options }];
|
||||
}, [settings?.mpvSubtitleAlignX, updateSettings]);
|
||||
|
||||
const alignYOptionGroups = useMemo(() => {
|
||||
const options = alignYOptions.map((align) => ({
|
||||
type: "radio" as const,
|
||||
label: alignYLabels[align],
|
||||
value: align,
|
||||
selected: align === (settings?.mpvSubtitleAlignY ?? "bottom"),
|
||||
onPress: () => updateSettings({ mpvSubtitleAlignY: align }),
|
||||
}));
|
||||
return [{ options }];
|
||||
}, [settings?.mpvSubtitleAlignY, updateSettings]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup
|
||||
title='MPV Subtitle Settings'
|
||||
description={
|
||||
<Text className='text-[#8E8D91] text-xs'>
|
||||
Advanced subtitle customization for MPV player
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{!isTv && (
|
||||
<>
|
||||
<ListItem title='Vertical Margin'>
|
||||
<Stepper
|
||||
value={settings.mpvSubtitleMarginY ?? 0}
|
||||
step={5}
|
||||
min={0}
|
||||
max={100}
|
||||
onUpdate={(value) =>
|
||||
updateSettings({ mpvSubtitleMarginY: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title='Horizontal Alignment'>
|
||||
<PlatformDropdown
|
||||
groups={alignXOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{alignXLabels[settings?.mpvSubtitleAlignX ?? "center"]}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title='Horizontal Alignment'
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title='Vertical Alignment'>
|
||||
<PlatformDropdown
|
||||
groups={alignYOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{alignYLabels[settings?.mpvSubtitleAlignY ?? "bottom"]}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title='Vertical Alignment'
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ListItem title='Opaque Background'>
|
||||
<Switch
|
||||
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ mpvSubtitleBackgroundEnabled: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{settings.mpvSubtitleBackgroundEnabled && (
|
||||
<ListItem title='Background Opacity'>
|
||||
<Stepper
|
||||
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
||||
step={5}
|
||||
min={10}
|
||||
max={100}
|
||||
appendValue='%'
|
||||
onUpdate={(value) =>
|
||||
updateSettings({ mpvSubtitleBackgroundOpacity: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
66
components/settings/MpvVoSettings.tsx
Normal file
66
components/settings/MpvVoSettings.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [
|
||||
{ key: "home.settings.vo_driver.gpu_next", value: "gpu-next" },
|
||||
{ key: "home.settings.vo_driver.gpu", value: "gpu" },
|
||||
];
|
||||
|
||||
export const MpvVoSettings: React.FC = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const voDriverOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: VO_DRIVER_OPTIONS.map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: t(option.key),
|
||||
value: option.value,
|
||||
selected: option.value === (settings?.mpvVoDriver ?? "gpu-next"),
|
||||
onPress: () => updateSettings({ mpvVoDriver: option.value }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.mpvVoDriver, t, updateSettings],
|
||||
);
|
||||
|
||||
const currentVoDriverLabel = useMemo(() => {
|
||||
const option = VO_DRIVER_OPTIONS.find(
|
||||
(o) => o.value === (settings?.mpvVoDriver ?? "gpu-next"),
|
||||
);
|
||||
return option ? t(option.key) : t("home.settings.vo_driver.gpu_next");
|
||||
}, [settings?.mpvVoDriver, t]);
|
||||
|
||||
// Only show on Android
|
||||
if (Platform.OS !== "android") return null;
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<ListGroup title={t("home.settings.vo_driver.title")} className='mb-4'>
|
||||
<ListItem title={t("home.settings.vo_driver.vo_mode")}>
|
||||
<PlatformDropdown
|
||||
groups={voDriverOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{currentVoDriverLabel}
|
||||
</Text>
|
||||
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.vo_driver.vo_mode")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { TFunction } from "i18next";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
@@ -8,6 +7,7 @@ import { Linking, Switch, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -141,36 +141,6 @@ export const OtherSettings: React.FC = () => {
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{/* {(Platform.OS === "ios" || Platform.isTVOS)&& (
|
||||
<ListItem
|
||||
title={t("home.settings.other.video_player")}
|
||||
disabled={pluginSettings?.defaultPlayer?.locked}
|
||||
>
|
||||
<Dropdown
|
||||
data={Object.values(VideoPlayer).filter(isNumber)}
|
||||
disabled={pluginSettings?.defaultPlayer?.locked}
|
||||
keyExtractor={String}
|
||||
titleExtractor={(item) => t(`home.settings.other.video_players.${VideoPlayer[item]}`)}
|
||||
title={
|
||||
<TouchableOpacity className="flex flex-row items-center justify-between py-1.5 pl-3">
|
||||
<Text className="mr-1 text-[#8E8D91]">
|
||||
{t(`home.settings.other.video_players.${VideoPlayer[settings.defaultPlayer]}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-expand-sharp"
|
||||
size={18}
|
||||
color="#5A5960"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.other.orientation")}
|
||||
onSelected={(defaultPlayer) =>
|
||||
updateSettings({ defaultPlayer })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
)} */}
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.show_custom_menu_links")}
|
||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||
@@ -188,14 +158,6 @@ export const OtherSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
|
||||
<Switch
|
||||
value={settings.showLargeHomeCarousel}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ showLargeHomeCarousel: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/hide-libraries/page")}
|
||||
title={t("home.settings.other.hide_libraries")}
|
||||
@@ -234,7 +196,10 @@ export const OtherSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||
<ListItem
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
trigger={
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Switch, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
@@ -28,6 +29,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
const orientations = [
|
||||
ScreenOrientation.OrientationLock.DEFAULT,
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE,
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
|
||||
];
|
||||
@@ -38,6 +40,8 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
"home.settings.other.orientations.DEFAULT",
|
||||
[ScreenOrientation.OrientationLock.PORTRAIT_UP]:
|
||||
"home.settings.other.orientations.PORTRAIT_UP",
|
||||
[ScreenOrientation.OrientationLock.LANDSCAPE]:
|
||||
"home.settings.other.orientations.LANDSCAPE",
|
||||
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]:
|
||||
"home.settings.other.orientations.LANDSCAPE_LEFT",
|
||||
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]:
|
||||
@@ -92,6 +96,21 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
||||
);
|
||||
|
||||
const playbackSpeedOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||
type: "radio" as const,
|
||||
label: speed.label,
|
||||
value: speed.value,
|
||||
selected: speed.value === settings?.defaultPlaybackSpeed,
|
||||
onPress: () => updateSettings({ defaultPlaybackSpeed: speed.value }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.defaultPlaybackSpeed, updateSettings],
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
@@ -158,6 +177,30 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.default_playback_speed")}
|
||||
disabled={pluginSettings?.defaultPlaybackSpeed?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={playbackSpeedOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between pl-3 py-1.5'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{PLAYBACK_SPEEDS.find(
|
||||
(s) => s.value === settings.defaultPlaybackSpeed,
|
||||
)?.label ?? "1x"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.default_playback_speed")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.disable_haptic_feedback")}
|
||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||
@@ -171,7 +214,26 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||
<ListItem
|
||||
title={t("home.settings.other.auto_play_next_episode")}
|
||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.autoPlayNextEpisode}
|
||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||
onValueChange={(autoPlayNextEpisode) =>
|
||||
updateSettings({ autoPlayNextEpisode })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
disabled={
|
||||
!settings.autoPlayNextEpisode ||
|
||||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
|
||||
}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
trigger={
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
@@ -28,6 +28,16 @@ export const PluginSettings = () => {
|
||||
title='Marlin Search'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
||||
title='Streamystats'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||
title='KefinTweaks'
|
||||
showArrow
|
||||
/>
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
@@ -28,6 +28,11 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
const errorHapticFeedback = useHaptic("error");
|
||||
const snapPoints = useMemo(
|
||||
() => (Platform.OS === "android" ? ["100%"] : ["40%"]),
|
||||
[],
|
||||
);
|
||||
const isAndroid = Platform.OS === "android";
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -53,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
successHapticFeedback();
|
||||
Alert.alert(
|
||||
t("home.settings.quick_connect.success"),
|
||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||
t("home.settings.quick_connect.quick_connect_authorized"),
|
||||
);
|
||||
setQuickConnectCode(undefined);
|
||||
bottomSheetModalRef?.current?.close();
|
||||
@@ -92,7 +97,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
snapPoints={snapPoints}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
@@ -100,9 +105,10 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
keyboardBehavior='interactive'
|
||||
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
||||
keyboardBlurBehavior='restore'
|
||||
android_keyboardInputMode='adjustResize'
|
||||
topInset={isAndroid ? 0 : undefined}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -18,6 +18,7 @@ export const StorageSettings = () => {
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
const errorHapticFeedback = useHaptic("error");
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
@@ -34,6 +35,8 @@ export const StorageSettings = () => {
|
||||
used: (app.total - app.remaining) / app.total,
|
||||
};
|
||||
},
|
||||
// Keep the bar moving while a download is writing to disk.
|
||||
refetchInterval: 10 * 1000,
|
||||
});
|
||||
|
||||
const { data: storageLabel } = useQuery({
|
||||
@@ -42,14 +45,34 @@ export const StorageSettings = () => {
|
||||
enabled: Platform.OS === "android",
|
||||
});
|
||||
|
||||
const onDeleteClicked = async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
}
|
||||
const onDeleteClicked = () => {
|
||||
Alert.alert(
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.ok"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
} finally {
|
||||
// Reflect the freed space immediately instead of waiting for
|
||||
// the next poll.
|
||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const calculatePercentage = (value: number, total: number) => {
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import {
|
||||
OUTLINE_THICKNESS,
|
||||
type OutlineThickness,
|
||||
VLC_COLORS,
|
||||
type VLCColor,
|
||||
} from "@/constants/SubtitleConstants";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
@@ -29,6 +24,11 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const cultures = media.cultures;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Local state for OpenSubtitles API key (only commit on blur)
|
||||
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||
settings?.openSubtitlesApiKey || "",
|
||||
);
|
||||
|
||||
const subtitleModes = [
|
||||
SubtitlePlaybackMode.Default,
|
||||
SubtitlePlaybackMode.Smart,
|
||||
@@ -92,84 +92,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
];
|
||||
}, [settings?.subtitleMode, t, updateSettings]);
|
||||
|
||||
const textColorOptionGroups = useMemo(() => {
|
||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||
const options = colors.map((color) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.colors.${color}`),
|
||||
value: color,
|
||||
selected: (settings?.vlcTextColor || "White") === color,
|
||||
onPress: () => updateSettings({ vlcTextColor: color }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcTextColor, t, updateSettings]);
|
||||
|
||||
const backgroundColorOptionGroups = useMemo(() => {
|
||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||
const options = colors.map((color) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.colors.${color}`),
|
||||
value: color,
|
||||
selected: (settings?.vlcBackgroundColor || "Black") === color,
|
||||
onPress: () => updateSettings({ vlcBackgroundColor: color }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcBackgroundColor, t, updateSettings]);
|
||||
|
||||
const outlineColorOptionGroups = useMemo(() => {
|
||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||
const options = colors.map((color) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.colors.${color}`),
|
||||
value: color,
|
||||
selected: (settings?.vlcOutlineColor || "Black") === color,
|
||||
onPress: () => updateSettings({ vlcOutlineColor: color }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcOutlineColor, t, updateSettings]);
|
||||
|
||||
const outlineThicknessOptionGroups = useMemo(() => {
|
||||
const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[];
|
||||
const options = thicknesses.map((thickness) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.thickness.${thickness}`),
|
||||
value: thickness,
|
||||
selected: (settings?.vlcOutlineThickness || "Normal") === thickness,
|
||||
onPress: () => updateSettings({ vlcOutlineThickness: thickness }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcOutlineThickness, t, updateSettings]);
|
||||
|
||||
const backgroundOpacityOptionGroups = useMemo(() => {
|
||||
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
const options = opacities.map((opacity) => ({
|
||||
type: "radio" as const,
|
||||
label: `${Math.round((opacity / 255) * 100)}%`,
|
||||
value: opacity,
|
||||
selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity,
|
||||
onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcBackgroundOpacity, updateSettings]);
|
||||
|
||||
const outlineOpacityOptionGroups = useMemo(() => {
|
||||
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
const options = opacities.map((opacity) => ({
|
||||
type: "radio" as const,
|
||||
label: `${Math.round((opacity / 255) * 100)}%`,
|
||||
value: opacity,
|
||||
selected: (settings?.vlcOutlineOpacity ?? 255) === opacity,
|
||||
onPress: () => updateSettings({ vlcOutlineOpacity: opacity }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcOutlineOpacity, updateSettings]);
|
||||
|
||||
if (isTv) return null;
|
||||
if (!settings) return null;
|
||||
|
||||
@@ -244,132 +166,54 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
disabled={pluginSettings?.subtitleSize?.locked}
|
||||
>
|
||||
<Stepper
|
||||
value={settings.subtitleSize}
|
||||
value={settings.mpvSubtitleScale ?? 1.0}
|
||||
disabled={pluginSettings?.subtitleSize?.locked}
|
||||
step={5}
|
||||
min={0}
|
||||
max={120}
|
||||
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.text_color")}>
|
||||
<PlatformDropdown
|
||||
groups={textColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
step={0.1}
|
||||
min={0.1}
|
||||
max={3.0}
|
||||
onUpdate={(value) =>
|
||||
updateSettings({ mpvSubtitleScale: Math.round(value * 10) / 10 })
|
||||
}
|
||||
title={t("home.settings.subtitles.text_color")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.background_color")}>
|
||||
<PlatformDropdown
|
||||
groups={backgroundColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
</ListGroup>
|
||||
|
||||
{/* OpenSubtitles API Key for client-side subtitle fetching */}
|
||||
<ListGroup
|
||||
title={
|
||||
t("home.settings.subtitles.opensubtitles_title") || "OpenSubtitles"
|
||||
}
|
||||
description={
|
||||
<Text className='text-[#8E8D91] text-xs'>
|
||||
{t("home.settings.subtitles.opensubtitles_hint") ||
|
||||
"Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<View className='p-4'>
|
||||
<Text className='text-xs text-gray-400 mb-2'>
|
||||
{t("home.settings.subtitles.opensubtitles_api_key") || "API Key"}
|
||||
</Text>
|
||||
<Input
|
||||
className='border border-neutral-800'
|
||||
placeholder={
|
||||
t("home.settings.subtitles.opensubtitles_api_key_placeholder") ||
|
||||
"Enter API key..."
|
||||
}
|
||||
title={t("home.settings.subtitles.background_color")}
|
||||
value={openSubtitlesApiKey}
|
||||
onChangeText={setOpenSubtitlesApiKey}
|
||||
onBlur={() => {
|
||||
updateSettings({ openSubtitlesApiKey });
|
||||
}}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_color")}>
|
||||
<PlatformDropdown
|
||||
groups={outlineColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.outline_color")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_thickness")}>
|
||||
<PlatformDropdown
|
||||
groups={outlineThicknessOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.outline_thickness")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
||||
<PlatformDropdown
|
||||
groups={backgroundOpacityOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.background_opacity")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_opacity")}>
|
||||
<PlatformDropdown
|
||||
groups={outlineOpacityOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.outline_opacity")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.bold_text")}>
|
||||
<Switch
|
||||
value={settings?.vlcIsBold ?? false}
|
||||
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
<Text className='text-xs text-gray-500 mt-2'>
|
||||
{t("home.settings.subtitles.opensubtitles_get_key") ||
|
||||
"Get your free API key at opensubtitles.com/en/consumers"}
|
||||
</Text>
|
||||
</View>
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as Application from "expo-application";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getVersionInfo } from "@/utils/version";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
@@ -13,10 +13,9 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const version =
|
||||
Application?.nativeApplicationVersion ||
|
||||
Application?.nativeBuildVersion ||
|
||||
"N/A";
|
||||
// Graduated build identifier — see utils/version.ts:
|
||||
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
|
||||
const { display: version } = getVersionInfo();
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
|
||||
Reference in New Issue
Block a user