mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 13:38:27 +01:00
Compare commits
26 Commits
feat/unifi
...
feat/setti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8718244f40 | ||
|
|
2245c84d2c | ||
|
|
ba0b646576 | ||
|
|
bf6010b3c7 | ||
|
|
8071e76fc6 | ||
|
|
8a88706776 | ||
|
|
db9540f90a | ||
|
|
9f3014a430 | ||
|
|
da47ad0502 | ||
|
|
059c8bb854 | ||
|
|
583fac6938 | ||
|
|
f6333b49d3 | ||
|
|
f56b2bc4eb | ||
|
|
ac5dcbe751 | ||
|
|
752d1179bc | ||
|
|
6a38c393e6 | ||
|
|
e476e0b4d9 | ||
|
|
e783227ba6 | ||
|
|
a0e6b31500 | ||
|
|
4e3f5a72e0 | ||
|
|
25c0663d2f | ||
|
|
81e97740f7 | ||
|
|
1349ad7dc0 | ||
|
|
4df9690d22 | ||
|
|
3dfae1a1b4 | ||
|
|
c7e3ceeefb |
@@ -243,6 +243,28 @@ export default function IndexLayout() {
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/account/page'
|
||||
options={{
|
||||
title: t("home.settings.account.title"),
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/notifications/page'
|
||||
options={{
|
||||
title: t("home.settings.notifications.title"),
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
|
||||
@@ -1,38 +1,44 @@
|
||||
import { useNavigation } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||
import { SettingsHero } from "@/components/settings/index/SettingsHero";
|
||||
import { SettingsRow } from "@/components/settings/index/SettingsRow";
|
||||
import { SettingsSearchBar } from "@/components/settings/index/SettingsSearchBar";
|
||||
import { SettingsSection } from "@/components/settings/index/SettingsSection";
|
||||
import {
|
||||
SETTINGS_CATALOG,
|
||||
type SettingsTarget,
|
||||
} from "@/components/settings/index/settingsCatalog";
|
||||
import { useSettingsSearch } from "@/components/settings/index/useSettingsSearch";
|
||||
import {
|
||||
QuickConnectSheet,
|
||||
type QuickConnectSheetRef,
|
||||
} from "@/components/settings/QuickConnect";
|
||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||
import { UserInfo } from "@/components/settings/UserInfo";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||
|
||||
// TV-specific settings component
|
||||
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
|
||||
|
||||
// Mobile settings component
|
||||
function SettingsMobile() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [_user] = useAtom(userAtom);
|
||||
const { logout } = useJellyfin();
|
||||
|
||||
const navigation = useNavigation();
|
||||
const quickConnectRef = useRef<QuickConnectSheetRef>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
|
||||
const results = useSettingsSearch(query);
|
||||
const searching = query.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity onPress={() => logout()}>
|
||||
<Text className='text-red-600 px-2'>
|
||||
{t("home.settings.log_out_button")}
|
||||
</Text>
|
||||
@@ -41,98 +47,95 @@ function SettingsMobile() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTarget = (target: SettingsTarget) => {
|
||||
if (target.type === "action") {
|
||||
if (target.action === "quickConnect") {
|
||||
quickConnectRef.current?.present();
|
||||
}
|
||||
return;
|
||||
}
|
||||
router.push(target.route as any);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
keyboardShouldPersistTaps='handled'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 32,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<View className='mb-4'>
|
||||
<UserInfo />
|
||||
</View>
|
||||
{!searching && (
|
||||
<SettingsHero
|
||||
onPress={() => router.push("/settings/account/page" as any)}
|
||||
/>
|
||||
)}
|
||||
<SettingsSearchBar value={query} onChange={setQuery} />
|
||||
|
||||
<QuickConnect className='mb-4' />
|
||||
|
||||
{Platform.OS !== "ios" && (
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||
<ListItem
|
||||
onPress={() =>
|
||||
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||
}
|
||||
title={t("pairing.pair_with_phone")}
|
||||
textColor='blue'
|
||||
{searching ? (
|
||||
<SettingsSection title={t("home.settings.search_results")}>
|
||||
{results.length === 0 ? (
|
||||
<View className='px-4 py-3'>
|
||||
<Text className='text-[#9899A1]'>
|
||||
{t("home.settings.search_no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
results.map((r, i) => (
|
||||
<SettingsRow
|
||||
key={r.id}
|
||||
title={r.title}
|
||||
icon={r.icon}
|
||||
value={r.subtitle}
|
||||
onPress={() => handleTarget(r.target)}
|
||||
isLast={i === results.length - 1}
|
||||
/>
|
||||
</ListGroup>
|
||||
))
|
||||
)}
|
||||
</SettingsSection>
|
||||
) : (
|
||||
<>
|
||||
<View className='mx-3 mb-5'>
|
||||
<AppLanguageSelector />
|
||||
</View>
|
||||
)}
|
||||
{SETTINGS_CATALOG.map((section) => {
|
||||
const entries = section.entries.filter(
|
||||
(e) => !e.platforms || e.platforms.includes(os),
|
||||
);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<SettingsSection key={section.id} title={t(section.titleKey)}>
|
||||
{entries.map((e, i) => (
|
||||
<SettingsRow
|
||||
key={e.id}
|
||||
title={t(e.titleKey)}
|
||||
icon={e.icon}
|
||||
onPress={() => handleTarget(e.target)}
|
||||
isLast={i === entries.length - 1}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
);
|
||||
})}
|
||||
<SettingsSection>
|
||||
<View className='p-3'>
|
||||
<StorageSettings />
|
||||
</View>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View className='mb-4'>
|
||||
<AppLanguageSelector />
|
||||
</View>
|
||||
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("home.settings.categories.title")}>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/playback-controls/page")}
|
||||
showArrow
|
||||
title={t("home.settings.playback_controls.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/audio-subtitles/page")}
|
||||
showArrow
|
||||
title={t("home.settings.audio_subtitles.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/music/page")}
|
||||
showArrow
|
||||
title={t("home.settings.music.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/appearance/page")}
|
||||
showArrow
|
||||
title={t("home.settings.appearance.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/page")}
|
||||
showArrow
|
||||
title={t("home.settings.plugins.plugins_title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/intro/page")}
|
||||
showArrow
|
||||
title={t("home.settings.intro.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/network/page")}
|
||||
showArrow
|
||||
title={t("home.settings.network.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/logs/page")}
|
||||
showArrow
|
||||
title={t("home.settings.logs.logs_title")}
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
<StorageSettings />
|
||||
</View>
|
||||
<QuickConnectSheet ref={quickConnectRef} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
export default function settings() {
|
||||
// Use TV settings component on TV platforms
|
||||
if (Platform.isTV && SettingsTV) {
|
||||
return <SettingsTV />;
|
||||
}
|
||||
|
||||
return <SettingsMobile />;
|
||||
}
|
||||
|
||||
60
app/(auth)/(tabs)/(home)/settings/account/page.tsx
Normal file
60
app/(auth)/(tabs)/(home)/settings/account/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as Application from "expo-application";
|
||||
import { setStringAsync } from "expo-clipboard";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { Alert, ScrollView } from "react-native";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export default function AccountPage() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const success = useHaptic("success");
|
||||
const version = Application.nativeApplicationVersion ?? "N/A";
|
||||
const token = api?.accessToken ?? "";
|
||||
const masked = token ? `•••• •••• •••• ${token.slice(-4)}` : "";
|
||||
|
||||
const copyToken = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
await setStringAsync(token);
|
||||
success();
|
||||
Alert.alert(t("home.settings.account.copied"));
|
||||
} catch {
|
||||
Alert.alert(t("home.settings.account.copy_failed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||
<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={revealed ? token : masked}
|
||||
onPress={() => setRevealed((r) => !r)}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.account.copy_token")}
|
||||
textColor='blue'
|
||||
onPress={copyToken}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.user_info.app_version")}
|
||||
value={version}
|
||||
/>
|
||||
</ListGroup>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
68
app/(auth)/(tabs)/(home)/settings/notifications/page.tsx
Normal file
68
app/(auth)/(tabs)/(home)/settings/notifications/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { t } from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Linking, ScrollView, Switch, View } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const [perm, setPerm] =
|
||||
useState<Notifications.NotificationPermissionsStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Notifications.getPermissionsAsync().then(setPerm);
|
||||
}, []);
|
||||
|
||||
const requestPermission = async () => {
|
||||
const res = await Notifications.requestPermissionsAsync();
|
||||
setPerm(res);
|
||||
// Only bounce to system settings when the OS will not prompt again.
|
||||
if (!res.granted && res.canAskAgain === false) {
|
||||
Linking.openSettings();
|
||||
}
|
||||
};
|
||||
|
||||
if (!settings || perm === null) return null;
|
||||
|
||||
if (!perm.granted) {
|
||||
return (
|
||||
<View className='flex-1 items-center justify-center px-8'>
|
||||
<Ionicons name='notifications-off-outline' size={56} color='#5A5960' />
|
||||
<Text className='text-white text-lg font-semibold mt-4 text-center'>
|
||||
{t("home.settings.notifications.disabled_title")}
|
||||
</Text>
|
||||
<Text className='text-[#9899A1] text-center mt-2'>
|
||||
{t("home.settings.notifications.disabled_description")}
|
||||
</Text>
|
||||
<Button color='purple' className='mt-6' onPress={requestPermission}>
|
||||
{t("home.settings.notifications.enable_button")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||
<ListGroup title={t("home.settings.notifications.events_title")}>
|
||||
<ListItem title={t("home.settings.notifications.master")}>
|
||||
<Switch
|
||||
value={settings.notificationsEnabled}
|
||||
onValueChange={(v) => updateSettings({ notificationsEnabled: v })}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.notifications.downloads")}>
|
||||
<Switch
|
||||
value={settings.notifyDownloads}
|
||||
disabled={!settings.notificationsEnabled}
|
||||
onValueChange={(v) => updateSettings({ notifyDownloads: v })}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -20,18 +20,16 @@ export default function PlaybackControlsPage() {
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
className='p-4'
|
||||
style={{ gap: 16, paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<View className='mb-4'>
|
||||
<MediaProvider>
|
||||
<MediaToggles className='mb-4' />
|
||||
<GestureControls className='mb-4' />
|
||||
<PlaybackControlsSettings />
|
||||
<MpvBufferSettings />
|
||||
<MpvVoSettings />
|
||||
</MediaProvider>
|
||||
</View>
|
||||
<MediaProvider>
|
||||
<MediaToggles />
|
||||
<GestureControls />
|
||||
<PlaybackControlsSettings />
|
||||
<MpvBufferSettings />
|
||||
<MpvVoSettings />
|
||||
</MediaProvider>
|
||||
{!Platform.isTV && <ChromecastSettings />}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -11,15 +11,12 @@ import {
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { ServerUrlStatusText } from "@/components/common/ServerUrlStatusText";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { reachabilityProbe } from "@/utils/serverUrl/probes/reachability";
|
||||
|
||||
export default function MarlinSearchPage() {
|
||||
const navigation = useNavigation();
|
||||
@@ -32,7 +29,6 @@ export default function MarlinSearchPage() {
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||
const urlResolver = useServerUrlResolver(reachabilityProbe);
|
||||
|
||||
const onSave = (val: string) => {
|
||||
updateSettings({
|
||||
@@ -131,17 +127,8 @@ export default function MarlinSearchPage() {
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={(text) => setValue(text)}
|
||||
onBlur={() => {
|
||||
const candidate = value.trim();
|
||||
if (candidate) {
|
||||
urlResolver.resolve(candidate).then((r) => {
|
||||
if (r.ok) setValue(r.url);
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<ServerUrlStatusText state={urlResolver} className='mt-1' />
|
||||
</DisabledSetting>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||
|
||||
@@ -11,14 +11,11 @@ import {
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { ServerUrlStatusText } from "@/components/common/ServerUrlStatusText";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { reachabilityProbe } from "@/utils/serverUrl/probes/reachability";
|
||||
|
||||
export default function StreamystatsPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -35,7 +32,6 @@ export default function StreamystatsPage() {
|
||||
|
||||
// Local state for all editable fields
|
||||
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
|
||||
const urlResolver = useServerUrlResolver(reachabilityProbe);
|
||||
const [useForSearch, setUseForSearch] = useState<boolean>(
|
||||
settings?.searchEngine === "Streamystats",
|
||||
);
|
||||
@@ -156,20 +152,9 @@ export default function StreamystatsPage() {
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={setUrl}
|
||||
onBlur={() => {
|
||||
const candidate = url.trim();
|
||||
if (candidate) {
|
||||
urlResolver.resolve(candidate).then((r) => {
|
||||
if (r.ok) setUrl(r.url);
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
<View className='px-4 mt-1'>
|
||||
<ServerUrlStatusText state={urlResolver} />
|
||||
</View>
|
||||
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}
|
||||
|
||||
21
bun.lock
21
bun.lock
@@ -31,6 +31,7 @@
|
||||
"expo-brightness": "~56.0.5",
|
||||
"expo-build-properties": "~56.0.15",
|
||||
"expo-camera": "~56.0.7",
|
||||
"expo-clipboard": "~56.0.3",
|
||||
"expo-constants": "~56.0.16",
|
||||
"expo-crypto": "~56.0.4",
|
||||
"expo-dev-client": "~56.0.16",
|
||||
@@ -103,8 +104,10 @@
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@react-native-community/cli": "20.1.3",
|
||||
"@react-native-tvos/config-tv": "0.1.6",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/node": "^24",
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
@@ -587,6 +590,8 @@
|
||||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="],
|
||||
|
||||
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
|
||||
@@ -603,7 +608,7 @@
|
||||
|
||||
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
||||
|
||||
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
"@types/node": ["@types/node@24.13.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
|
||||
|
||||
@@ -743,6 +748,8 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
|
||||
@@ -955,6 +962,8 @@
|
||||
|
||||
"expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="],
|
||||
|
||||
"expo-clipboard": ["expo-clipboard@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-8mCdhmAomm0yBIonJFjAhKUXvSkc2avdNh4+rBwoe7DSWF2AC4w3uy+pa419rvVFbTyVxOBmh83UHAbUwD6qAg=="],
|
||||
|
||||
"expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="],
|
||||
|
||||
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
||||
@@ -1827,7 +1836,7 @@
|
||||
|
||||
"ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="],
|
||||
|
||||
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
|
||||
|
||||
@@ -2031,6 +2040,8 @@
|
||||
|
||||
"brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||
|
||||
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
@@ -2129,6 +2140,8 @@
|
||||
|
||||
"nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"node-vibrant/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"npm-package-arg/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
||||
|
||||
"parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
|
||||
@@ -2247,6 +2260,8 @@
|
||||
|
||||
"ansi-fragments/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
@@ -2303,6 +2318,8 @@
|
||||
|
||||
"metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"patch-package/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
|
||||
import type { ResolveOptions } from "@/utils/serverUrl/resolve";
|
||||
import type { ServerProbe } from "@/utils/serverUrl/types";
|
||||
import { Input } from "./Input";
|
||||
import { ServerUrlStatusText } from "./ServerUrlStatusText";
|
||||
import { Text } from "./Text";
|
||||
|
||||
interface ServerUrlFieldProps {
|
||||
/** Raw user input (controlled). */
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
/** Service-specific validator. Pass a stable (module-level) reference. */
|
||||
probe: ServerProbe;
|
||||
/** Called with the canonical URL once a candidate validates. */
|
||||
onResolved?: (url: string, meta?: Record<string, unknown>) => void;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
placeholder?: string;
|
||||
editable?: boolean;
|
||||
resolveOptions?: ResolveOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified server-URL input: the user types a loose address (`media.example.com`,
|
||||
* `https://…`, `host:port`); on blur it auto-resolves via the given probe,
|
||||
* adopts the canonical URL into the field, and persists it. A small status line
|
||||
* (checking / resolved / error) shows underneath.
|
||||
*/
|
||||
export function ServerUrlField({
|
||||
value,
|
||||
onChangeText,
|
||||
probe,
|
||||
onResolved,
|
||||
label,
|
||||
hint,
|
||||
placeholder,
|
||||
editable = true,
|
||||
resolveOptions,
|
||||
}: ServerUrlFieldProps) {
|
||||
const resolver = useServerUrlResolver(probe, resolveOptions);
|
||||
const lastResolvedInput = useRef<string | null>(null);
|
||||
|
||||
const runResolve = useCallback(async () => {
|
||||
const input = value.trim();
|
||||
if (!input) {
|
||||
resolver.reset();
|
||||
lastResolvedInput.current = null;
|
||||
return;
|
||||
}
|
||||
lastResolvedInput.current = input;
|
||||
const result = await resolver.resolve(input);
|
||||
if (result.ok) {
|
||||
onChangeText(result.url); // adopt the canonical URL into the field
|
||||
onResolved?.(result.url, result.meta);
|
||||
}
|
||||
}, [value, resolver, onChangeText, onResolved]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
const input = value.trim();
|
||||
if (input && input !== lastResolvedInput.current) runResolve();
|
||||
}, [value, runResolve]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(text: string) => {
|
||||
onChangeText(text);
|
||||
// Editing invalidates a previous result; drop the stale status.
|
||||
if (resolver.status !== "idle") resolver.reset();
|
||||
lastResolvedInput.current = null;
|
||||
},
|
||||
[onChangeText, resolver],
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
{label ? <Text className='font-bold mb-1'>{label}</Text> : null}
|
||||
{hint ? <Text className='text-xs text-gray-500 mb-2'>{hint}</Text> : null}
|
||||
|
||||
<Input
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onSubmitEditing={runResolve}
|
||||
placeholder={placeholder}
|
||||
editable={editable}
|
||||
extraClassName='border border-neutral-800'
|
||||
keyboardType='url'
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
returnKeyType='go'
|
||||
textContentType='URL'
|
||||
clearButtonMode='never'
|
||||
/>
|
||||
|
||||
<ServerUrlStatusText state={resolver} className='mt-2' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import type { ServerUrlResolverState } from "@/hooks/useServerUrlResolver";
|
||||
import { Text } from "./Text";
|
||||
|
||||
/**
|
||||
* Compact status line for the server-URL resolver, for screens whose layout
|
||||
* (e.g. ListItem rows) doesn't fit the full `ServerUrlField`. Renders nothing
|
||||
* while idle.
|
||||
*/
|
||||
export function ServerUrlStatusText({
|
||||
state,
|
||||
className = "",
|
||||
}: {
|
||||
state: ServerUrlResolverState;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (state.status === "idle") return null;
|
||||
|
||||
if (state.status === "resolving") {
|
||||
return (
|
||||
<View className={`flex-row items-center ${className}`}>
|
||||
<ActivityIndicator size='small' color='#9ca3af' />
|
||||
<Text className='text-xs text-neutral-400 ml-2'>
|
||||
{t("server_url.resolving")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "ok") {
|
||||
return (
|
||||
<Text className={`text-xs text-green-500 ${className}`}>
|
||||
{t("server_url.resolved", { url: state.resolvedUrl })}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const message =
|
||||
state.reason === "wrong-service"
|
||||
? t("server_url.wrong_service")
|
||||
: state.reason === "invalid"
|
||||
? t("server_url.invalid_url")
|
||||
: t("server_url.unreachable");
|
||||
|
||||
return <Text className={`text-xs text-red-500 ${className}`}>{message}</Text>;
|
||||
}
|
||||
@@ -11,13 +11,10 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { ServerUrlStatusText } from "@/components/common/ServerUrlStatusText";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useServerUrlResolver } from "@/hooks/useServerUrlResolver";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { sendCredentialsToTV } from "@/utils/pairingService";
|
||||
import { jellyfinProbe } from "@/utils/serverUrl/probes/jellyfin";
|
||||
|
||||
type ScreenState =
|
||||
| "scanning"
|
||||
@@ -52,7 +49,6 @@ export const CompanionLoginScreen: React.FC = () => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const serverResolver = useServerUrlResolver(jellyfinProbe);
|
||||
|
||||
// Pre-fill server URL and username from current session
|
||||
useEffect(() => {
|
||||
@@ -409,16 +405,7 @@ export const CompanionLoginScreen: React.FC = () => {
|
||||
autoCorrect={false}
|
||||
keyboardType='url'
|
||||
returnKeyType='next'
|
||||
onBlur={() => {
|
||||
const candidate = serverUrl.trim();
|
||||
if (candidate) {
|
||||
serverResolver.resolve(candidate).then((r) => {
|
||||
if (r.ok) setServerUrl(r.url);
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ServerUrlStatusText state={serverResolver} className='mt-2' />
|
||||
</View>
|
||||
|
||||
<View className='mb-5'>
|
||||
|
||||
@@ -34,12 +34,13 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
}) => {
|
||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||
const isDisabled = disabled || disabledByAdmin;
|
||||
const hasSubtitle = Boolean(effectiveSubtitle);
|
||||
if (onPress)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={isDisabled}
|
||||
onPress={onPress}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${hasSubtitle ? "min-h-[48px] py-2" : "h-[48px]"} px-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
<ListItemContent
|
||||
@@ -58,7 +59,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
);
|
||||
return (
|
||||
<View
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${hasSubtitle ? "min-h-[48px] py-2" : "h-[48px]"} px-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||
{...viewProps}
|
||||
>
|
||||
<ListItemContent
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { Switch, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup title={"Chromecast"}>
|
||||
<ListItem title={"Enable H265 for Chromecast"}>
|
||||
<Switch
|
||||
value={settings.enableH265ForChromecast}
|
||||
onValueChange={(enableH265ForChromecast) =>
|
||||
updateSettings({ enableH265ForChromecast })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListGroup title={t("home.settings.chromecast.title")}>
|
||||
<SettingsSwitchRow
|
||||
title={t("home.settings.chromecast.enable_h265")}
|
||||
value={settings.enableH265ForChromecast}
|
||||
onValueChange={(enableH265ForChromecast) =>
|
||||
updateSettings({ enableH265ForChromecast })
|
||||
}
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -2,11 +2,10 @@ import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ViewProps } from "react-native";
|
||||
import { Switch } from "react-native";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -32,85 +31,65 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
<ListGroup
|
||||
title={t("home.settings.gesture_controls.gesture_controls_title")}
|
||||
>
|
||||
<ListItem
|
||||
<SettingsSwitchRow
|
||||
title={t("home.settings.gesture_controls.horizontal_swipe_skip")}
|
||||
subtitle={t(
|
||||
"home.settings.gesture_controls.horizontal_swipe_skip_description",
|
||||
)}
|
||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.enableHorizontalSwipeSkip}
|
||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||
onValueChange={(enableHorizontalSwipeSkip) =>
|
||||
updateSettings({ enableHorizontalSwipeSkip })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
value={settings.enableHorizontalSwipeSkip}
|
||||
onValueChange={(enableHorizontalSwipeSkip) =>
|
||||
updateSettings({ enableHorizontalSwipeSkip })
|
||||
}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
<SettingsSwitchRow
|
||||
title={t("home.settings.gesture_controls.left_side_brightness")}
|
||||
subtitle={t(
|
||||
"home.settings.gesture_controls.left_side_brightness_description",
|
||||
)}
|
||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.enableLeftSideBrightnessSwipe}
|
||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
||||
updateSettings({ enableLeftSideBrightnessSwipe })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
value={settings.enableLeftSideBrightnessSwipe}
|
||||
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
||||
updateSettings({ enableLeftSideBrightnessSwipe })
|
||||
}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
<SettingsSwitchRow
|
||||
title={t("home.settings.gesture_controls.right_side_volume")}
|
||||
subtitle={t(
|
||||
"home.settings.gesture_controls.right_side_volume_description",
|
||||
)}
|
||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.enableRightSideVolumeSwipe}
|
||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||
onValueChange={(enableRightSideVolumeSwipe) =>
|
||||
updateSettings({ enableRightSideVolumeSwipe })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
value={settings.enableRightSideVolumeSwipe}
|
||||
onValueChange={(enableRightSideVolumeSwipe) =>
|
||||
updateSettings({ enableRightSideVolumeSwipe })
|
||||
}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
<SettingsSwitchRow
|
||||
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>
|
||||
value={settings.hideVolumeSlider}
|
||||
onValueChange={(hideVolumeSlider) =>
|
||||
updateSettings({ hideVolumeSlider })
|
||||
}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
<SettingsSwitchRow
|
||||
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>
|
||||
value={settings.hideBrightnessSlider}
|
||||
onValueChange={(hideBrightnessSlider) =>
|
||||
updateSettings({ hideBrightnessSlider })
|
||||
}
|
||||
/>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -7,11 +7,8 @@ import { toast } from "sonner-native";
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { jellyseerrProbe } from "@/utils/serverUrl/probes/jellyseerr";
|
||||
import { resolveServerUrl } from "@/utils/serverUrl/resolve";
|
||||
import { Button } from "../Button";
|
||||
import { Input } from "../common/Input";
|
||||
import { ServerUrlField } from "../common/ServerUrlField";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
@@ -29,44 +26,26 @@ export const JellyseerrSettings = () => {
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<string>(
|
||||
settings?.jellyseerrServerUrl ?? "",
|
||||
);
|
||||
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>(
|
||||
settings?.jellyseerrServerUrl ?? undefined,
|
||||
);
|
||||
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<
|
||||
string | undefined
|
||||
>(settings?.jellyseerrServerUrl || undefined);
|
||||
|
||||
const loginToJellyseerrMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
||||
throw new Error("Missing server url");
|
||||
if (!user?.Name)
|
||||
throw new Error("Missing required information for login");
|
||||
|
||||
// Prefer the already-resolved URL; otherwise resolve the raw input now
|
||||
// (covers tapping Login before the field's on-blur resolve settled).
|
||||
let finalUrl = resolvedUrl || settings?.jellyseerrServerUrl || "";
|
||||
if (!finalUrl && jellyseerrServerUrl) {
|
||||
const resolved = await resolveServerUrl(
|
||||
jellyseerrServerUrl,
|
||||
jellyseerrProbe,
|
||||
);
|
||||
if (!resolved.ok) throw new Error("Invalid server url");
|
||||
finalUrl = resolved.url;
|
||||
}
|
||||
if (!finalUrl) throw new Error("Missing server url");
|
||||
|
||||
const jellyseerrTempApi = new JellyseerrApi(finalUrl);
|
||||
const jellyseerrTempApi = new JellyseerrApi(
|
||||
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
|
||||
);
|
||||
const testResult = await jellyseerrTempApi.test();
|
||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||
const loggedInUser = await jellyseerrTempApi.login(
|
||||
user.Name,
|
||||
jellyseerrPassword || "",
|
||||
);
|
||||
return { user: loggedInUser, url: finalUrl };
|
||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
||||
},
|
||||
onSuccess: ({ user: loggedInUser, url }) => {
|
||||
setJellyseerrUser(loggedInUser);
|
||||
setResolvedUrl(url);
|
||||
updateSettings({ jellyseerrServerUrl: url });
|
||||
onSuccess: (user) => {
|
||||
setJellyseerrUser(user);
|
||||
updateSettings({ jellyseerrServerUrl });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("jellyseerr.failed_to_login"));
|
||||
@@ -80,8 +59,7 @@ export const JellyseerrSettings = () => {
|
||||
clearAllJellyseerData().finally(() => {
|
||||
setJellyseerrUser(undefined);
|
||||
setJellyseerrPassword(undefined);
|
||||
setjellyseerrServerUrl("");
|
||||
setResolvedUrl(undefined);
|
||||
setjellyseerrServerUrl(undefined);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -140,20 +118,30 @@ export const JellyseerrSettings = () => {
|
||||
<Text className='text-xs text-red-600 mb-2'>
|
||||
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||
</Text>
|
||||
<View className='mb-2'>
|
||||
<ServerUrlField
|
||||
value={jellyseerrServerUrl}
|
||||
onChangeText={setjellyseerrServerUrl}
|
||||
onResolved={(url) => setResolvedUrl(url)}
|
||||
probe={jellyseerrProbe}
|
||||
label={t("home.settings.plugins.jellyseerr.server_url")}
|
||||
hint={t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||
)}
|
||||
editable={!loginToJellyseerrMutation.isPending}
|
||||
/>
|
||||
<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'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Input
|
||||
className='border border-neutral-800 mb-2'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||
)}
|
||||
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
||||
defaultValue={
|
||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||
}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={setjellyseerrServerUrl}
|
||||
editable={!loginToJellyseerrMutation.isPending}
|
||||
/>
|
||||
<View>
|
||||
<Text className='font-bold mb-2'>
|
||||
{t("home.settings.plugins.jellyseerr.password")}
|
||||
|
||||
@@ -12,9 +12,8 @@ import {
|
||||
type LocalNetworkConfig,
|
||||
updateServerLocalConfig,
|
||||
} from "@/utils/secureCredentials";
|
||||
import { jellyfinProbe } from "@/utils/serverUrl/probes/jellyfin";
|
||||
import { Button } from "../Button";
|
||||
import { ServerUrlField } from "../common/ServerUrlField";
|
||||
import { Input } from "../common/Input";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
@@ -163,12 +162,13 @@ export function LocalNetworkSettings(): React.ReactElement | null {
|
||||
}
|
||||
>
|
||||
<View className=''>
|
||||
<ServerUrlField
|
||||
<Input
|
||||
placeholder={t("home.settings.network.local_url_placeholder")}
|
||||
value={config.localUrl}
|
||||
onChangeText={handleLocalUrlChange}
|
||||
onResolved={(url) => saveConfig({ ...config, localUrl: url })}
|
||||
probe={jellyfinProbe}
|
||||
placeholder={t("home.settings.network.local_url_placeholder")}
|
||||
keyboardType='url'
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
</ListGroup>
|
||||
|
||||
@@ -2,11 +2,10 @@ import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ViewProps } from "react-native";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { SettingsStepperRow } from "@/components/settings/index/SettingsStepperRow";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -27,35 +26,27 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
||||
return (
|
||||
<DisabledSetting disabled={disabled} {...props}>
|
||||
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
|
||||
<ListItem
|
||||
<SettingsStepperRow
|
||||
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>
|
||||
value={settings.forwardSkipTime}
|
||||
step={5}
|
||||
appendValue={t("home.settings.media_controls.seconds_unit")}
|
||||
min={0}
|
||||
max={60}
|
||||
onUpdate={(forwardSkipTime) => updateSettings({ forwardSkipTime })}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
<SettingsStepperRow
|
||||
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>
|
||||
value={settings.rewindSkipTime}
|
||||
step={5}
|
||||
appendValue={t("home.settings.media_controls.seconds_unit")}
|
||||
min={0}
|
||||
max={60}
|
||||
onUpdate={(rewindSkipTime) => updateSettings({ rewindSkipTime })}
|
||||
/>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
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 { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
||||
import { SettingsStepperRow } from "@/components/settings/index/SettingsStepperRow";
|
||||
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" },
|
||||
@@ -45,56 +41,43 @@ export const MpvBufferSettings: React.FC = () => {
|
||||
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>
|
||||
<ListGroup title={t("home.settings.buffer.title")}>
|
||||
<SettingsSelectRow
|
||||
title={t("home.settings.buffer.cache_mode")}
|
||||
valueLabel={currentCacheModeLabel}
|
||||
groups={cacheModeOptions}
|
||||
dropdownTitle={t("home.settings.buffer.cache_mode")}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<SettingsStepperRow
|
||||
title={t("home.settings.buffer.buffer_duration")}
|
||||
value={settings.mpvCacheSeconds ?? 10}
|
||||
step={5}
|
||||
min={5}
|
||||
max={120}
|
||||
onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })}
|
||||
appendValue='s'
|
||||
/>
|
||||
|
||||
<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>
|
||||
<SettingsStepperRow
|
||||
title={t("home.settings.buffer.max_cache_size")}
|
||||
value={settings.mpvDemuxerMaxBytes ?? 150}
|
||||
step={25}
|
||||
min={50}
|
||||
max={500}
|
||||
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })}
|
||||
appendValue=' MB'
|
||||
/>
|
||||
|
||||
<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>
|
||||
<SettingsStepperRow
|
||||
title={t("home.settings.buffer.max_backward_cache")}
|
||||
value={settings.mpvDemuxerMaxBackBytes ?? 50}
|
||||
step={25}
|
||||
min={25}
|
||||
max={200}
|
||||
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBackBytes: value })}
|
||||
appendValue=' MB'
|
||||
/>
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
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 { Platform } from "react-native";
|
||||
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
||||
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" },
|
||||
@@ -46,21 +43,13 @@ export const MpvVoSettings: React.FC = () => {
|
||||
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 title={t("home.settings.vo_driver.title")}>
|
||||
<SettingsSelectRow
|
||||
title={t("home.settings.vo_driver.vo_mode")}
|
||||
valueLabel={currentVoDriverLabel}
|
||||
groups={voDriverOptions}
|
||||
dropdownTitle={t("home.settings.vo_driver.vo_mode")}
|
||||
/>
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { TFunction } from "i18next";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
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 { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
||||
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const PlaybackControlsSettings: React.FC = () => {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
@@ -116,141 +113,77 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
return (
|
||||
<DisabledSetting disabled={disabled}>
|
||||
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
||||
<ListItem
|
||||
<SettingsSelectRow
|
||||
title={t("home.settings.other.video_orientation")}
|
||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={orientationOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
orientationTranslations[
|
||||
settings.defaultVideoOrientation as keyof typeof orientationTranslations
|
||||
],
|
||||
) || "Unknown Orientation"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.orientation")}
|
||||
/>
|
||||
</ListItem>
|
||||
valueLabel={
|
||||
t(
|
||||
orientationTranslations[
|
||||
settings.defaultVideoOrientation as keyof typeof orientationTranslations
|
||||
],
|
||||
) || "Unknown Orientation"
|
||||
}
|
||||
groups={orientationOptions}
|
||||
dropdownTitle={t("home.settings.other.orientation")}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
<SettingsSwitchRow
|
||||
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>
|
||||
value={settings.safeAreaInControlsEnabled}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ safeAreaInControlsEnabled: value })
|
||||
}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
<SettingsSelectRow
|
||||
title={t("home.settings.other.default_quality")}
|
||||
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={bitrateOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between pl-3 py-1.5 '>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{settings.defaultBitrate?.key}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.default_quality")}
|
||||
/>
|
||||
</ListItem>
|
||||
valueLabel={settings.defaultBitrate?.key}
|
||||
groups={bitrateOptions}
|
||||
dropdownTitle={t("home.settings.other.default_quality")}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
<SettingsSelectRow
|
||||
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>
|
||||
valueLabel={
|
||||
PLAYBACK_SPEEDS.find(
|
||||
(s) => s.value === settings.defaultPlaybackSpeed,
|
||||
)?.label ?? "1x"
|
||||
}
|
||||
groups={playbackSpeedOptions}
|
||||
dropdownTitle={t("home.settings.other.default_playback_speed")}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
<SettingsSwitchRow
|
||||
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>
|
||||
value={settings.disableHapticFeedback}
|
||||
onValueChange={(disableHapticFeedback) =>
|
||||
updateSettings({ disableHapticFeedback })
|
||||
}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
<SettingsSwitchRow
|
||||
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>
|
||||
value={settings.autoPlayNextEpisode}
|
||||
onValueChange={(autoPlayNextEpisode) =>
|
||||
updateSettings({ autoPlayNextEpisode })
|
||||
}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
<SettingsSelectRow
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
disabled={
|
||||
!settings.autoPlayNextEpisode ||
|
||||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
|
||||
}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
/>
|
||||
</ListItem>
|
||||
valueLabel={t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||
groups={autoPlayEpisodeOptions}
|
||||
dropdownTitle={t("home.settings.other.max_auto_play_episode_count")}
|
||||
/>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -1,54 +1,57 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
type BottomSheetMethods,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@/utils/expoUiBottomSheet";
|
||||
import { Button } from "../Button";
|
||||
import { Text } from "../common/Text";
|
||||
import { PinInput } from "../inputs/PinInput";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
export type QuickConnectSheetRef = { present: () => void };
|
||||
|
||||
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
const isTv = Platform.isTV;
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
||||
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";
|
||||
export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
||||
(_props, ref) => {
|
||||
const isTv = Platform.isTV;
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
||||
const modalRef = useRef<BottomSheetMethods>(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();
|
||||
|
||||
const { t } = useTranslation();
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
present: () => {
|
||||
setQuickConnectCode("");
|
||||
modalRef.current?.present();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const authorizeQuickConnect = useCallback(async () => {
|
||||
if (quickConnectCode) {
|
||||
const authorizeQuickConnect = useCallback(async () => {
|
||||
if (!quickConnectCode) return;
|
||||
try {
|
||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||
code: quickConnectCode,
|
||||
@@ -61,7 +64,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||
);
|
||||
setQuickConnectCode(undefined);
|
||||
bottomSheetModalRef?.current?.close();
|
||||
modalRef.current?.close();
|
||||
} else {
|
||||
errorHapticFeedback();
|
||||
Alert.alert(
|
||||
@@ -76,39 +79,26 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
t("home.settings.quick_connect.invalid_code"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [api, user, quickConnectCode]);
|
||||
}, [
|
||||
api,
|
||||
user,
|
||||
quickConnectCode,
|
||||
t,
|
||||
successHapticFeedback,
|
||||
errorHapticFeedback,
|
||||
]);
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
|
||||
<ListItem
|
||||
onPress={() => {
|
||||
// Reset the code when opening the sheet
|
||||
setQuickConnectCode("");
|
||||
bottomSheetModalRef?.current?.present();
|
||||
}}
|
||||
title={t("home.settings.quick_connect.authorize_button")}
|
||||
textColor='blue'
|
||||
/>
|
||||
</ListGroup>
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
ref={modalRef}
|
||||
enablePanDownToClose
|
||||
snapPoints={snapPoints}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||
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'>
|
||||
@@ -142,6 +132,8 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
QuickConnectSheet.displayName = "QuickConnectSheet";
|
||||
|
||||
81
components/settings/index/SettingsHero.tsx
Normal file
81
components/settings/index/SettingsHero.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type React from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserImageUrl } from "@/utils/jellyfin/image/getUserImageUrl";
|
||||
|
||||
export const SettingsHero: React.FC<{ onPress: () => void }> = ({
|
||||
onPress,
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const connected = Boolean(api && user);
|
||||
const imageUrl =
|
||||
api && user?.Id
|
||||
? (getUserImageUrl({
|
||||
serverAddress: api.basePath,
|
||||
userId: user.Id,
|
||||
primaryImageTag: user.PrimaryImageTag,
|
||||
}) ?? undefined)
|
||||
: undefined;
|
||||
const host = api?.basePath?.replace(/^https?:\/\//, "");
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
className='mx-3 mb-4 rounded-2xl overflow-hidden'
|
||||
>
|
||||
<LinearGradient
|
||||
colors={["#241b33", "#15151a"]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<View className='flex-row items-center p-4'>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: 52, height: 52, borderRadius: 26 }}
|
||||
/>
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={["#a855f7", "#6d28d9"]}
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 26,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text className='text-white text-[22px] font-bold'>
|
||||
{(user?.Name?.[0] ?? "?").toUpperCase()}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
)}
|
||||
<View className='flex-1 ml-3'>
|
||||
<Text
|
||||
className='text-white text-[18px] font-bold'
|
||||
numberOfLines={1}
|
||||
>
|
||||
{user?.Name ?? ""}
|
||||
</Text>
|
||||
<View className='flex-row items-center mt-0.5'>
|
||||
<View
|
||||
className='w-2 h-2 rounded-full mr-1.5'
|
||||
style={{ backgroundColor: connected ? "#30D158" : "#8E8D91" }}
|
||||
/>
|
||||
<Text className='text-[#9899A1] text-[13px]' numberOfLines={1}>
|
||||
{host}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name='chevron-forward' size={18} color='#5A5960' />
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
62
components/settings/index/SettingsRow.tsx
Normal file
62
components/settings/index/SettingsRow.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type React from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
export interface SettingsRowProps {
|
||||
title: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
value?: string;
|
||||
showChevron?: boolean;
|
||||
onPress: () => void;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const ACCENT = "#a855f7"; // single accent (full theming is a separate sub-project)
|
||||
|
||||
export const SettingsRow: React.FC<SettingsRowProps> = ({
|
||||
title,
|
||||
icon,
|
||||
value,
|
||||
showChevron = true,
|
||||
onPress,
|
||||
isLast = false,
|
||||
}) => {
|
||||
const haptic = useHaptic("light"); // no-op when disableHapticFeedback is set
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
haptic();
|
||||
onPress();
|
||||
}}
|
||||
className={`flex flex-row items-center bg-neutral-900 h-[48px] px-3 ${
|
||||
isLast ? "" : "border-b border-[#ffffff14]"
|
||||
}`}
|
||||
>
|
||||
<View
|
||||
className='h-[29px] w-[29px] rounded-[7px] items-center justify-center mr-3'
|
||||
style={{ backgroundColor: `${ACCENT}29` }}
|
||||
>
|
||||
<Ionicons name={icon} size={17} color='#c79bff' />
|
||||
</View>
|
||||
<Text className='flex-1 text-white text-[15px]' numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{value ? (
|
||||
<Text className='text-[#9899A1] text-[15px] ml-2' numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
) : null}
|
||||
{showChevron ? (
|
||||
<Ionicons
|
||||
name='chevron-forward'
|
||||
size={17}
|
||||
color='#5A5960'
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
28
components/settings/index/SettingsSearchBar.tsx
Normal file
28
components/settings/index/SettingsSearchBar.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { t } from "i18next";
|
||||
import type React from "react";
|
||||
import { TextInput, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export const SettingsSearchBar: React.FC<{
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}> = ({ value, onChange }) => (
|
||||
<View className='mx-3 mb-4 h-[38px] rounded-xl bg-neutral-800 flex-row items-center px-3'>
|
||||
<Ionicons name='search' size={16} color='#76767c' />
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder={t("home.settings.search_placeholder")}
|
||||
placeholderTextColor='#76767c'
|
||||
className='flex-1 ml-2 text-white text-[15px]'
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
returnKeyType='search'
|
||||
/>
|
||||
{value.length > 0 ? (
|
||||
<TouchableOpacity onPress={() => onChange("")} hitSlop={8}>
|
||||
<Ionicons name='close-circle' size={18} color='#76767c' />
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
19
components/settings/index/SettingsSection.tsx
Normal file
19
components/settings/index/SettingsSection.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type React from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
export const SettingsSection: React.FC<{
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ title, children }) => (
|
||||
<View className='mb-5'>
|
||||
{title ? (
|
||||
<Text className='ml-4 mb-1.5 uppercase text-[#8E8D91] text-[11px] tracking-wide'>
|
||||
{title}
|
||||
</Text>
|
||||
) : null}
|
||||
<View className='mx-3 rounded-xl overflow-hidden bg-neutral-900'>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
38
components/settings/index/SettingsSelectRow.tsx
Normal file
38
components/settings/index/SettingsSelectRow.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type React from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import {
|
||||
type OptionGroup,
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
valueLabel?: string;
|
||||
groups: OptionGroup[];
|
||||
dropdownTitle?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SettingsSelectRow: React.FC<Props> = ({
|
||||
title,
|
||||
valueLabel,
|
||||
groups,
|
||||
dropdownTitle,
|
||||
disabled,
|
||||
}) => (
|
||||
<ListItem title={title} disabled={disabled}>
|
||||
<PlatformDropdown
|
||||
groups={groups}
|
||||
title={dropdownTitle}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{valueLabel}</Text>
|
||||
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
39
components/settings/index/SettingsStepperRow.tsx
Normal file
39
components/settings/index/SettingsStepperRow.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type React from "react";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
value: number;
|
||||
step: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onUpdate: (value: number) => void;
|
||||
appendValue?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SettingsStepperRow: React.FC<Props> = ({
|
||||
title,
|
||||
subtitle,
|
||||
value,
|
||||
step,
|
||||
min,
|
||||
max,
|
||||
onUpdate,
|
||||
appendValue,
|
||||
disabled,
|
||||
}) => (
|
||||
<ListItem title={title} subtitle={subtitle} disabled={disabled}>
|
||||
<Stepper
|
||||
value={value}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
onUpdate={onUpdate}
|
||||
appendValue={appendValue}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
23
components/settings/index/SettingsSwitchRow.tsx
Normal file
23
components/settings/index/SettingsSwitchRow.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type React from "react";
|
||||
import { Switch } from "react-native";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SettingsSwitchRow: React.FC<Props> = ({
|
||||
title,
|
||||
subtitle,
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
}) => (
|
||||
<ListItem title={title} subtitle={subtitle} disabled={disabled}>
|
||||
<Switch value={value} disabled={disabled} onValueChange={onValueChange} />
|
||||
</ListItem>
|
||||
);
|
||||
22
components/settings/index/searchFilter.test.ts
Normal file
22
components/settings/index/searchFilter.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { matchesQuery, normalize } from "./searchFilter";
|
||||
|
||||
test("normalize strips accents and lowercases", () => {
|
||||
expect(normalize("Légèreté")).toBe("legerete");
|
||||
expect(normalize(" AUDIO ")).toBe("audio");
|
||||
});
|
||||
|
||||
test("matchesQuery matches title case/accent-insensitively", () => {
|
||||
expect(matchesQuery({ title: "Apparence", keywords: [] }, "appar")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
matchesQuery({ title: "Audio", keywords: ["sous-titres"] }, "SOUS"),
|
||||
).toBe(true);
|
||||
expect(matchesQuery({ title: "Music", keywords: [] }, "xyz")).toBe(false);
|
||||
});
|
||||
|
||||
test("matchesQuery returns true for empty query", () => {
|
||||
expect(matchesQuery({ title: "Anything" }, "")).toBe(true);
|
||||
expect(matchesQuery({ title: "Anything" }, " ")).toBe(true);
|
||||
});
|
||||
14
components/settings/index/searchFilter.ts
Normal file
14
components/settings/index/searchFilter.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const normalize = (s: string): string =>
|
||||
s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
|
||||
|
||||
export interface Searchable {
|
||||
title: string;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
export const matchesQuery = (item: Searchable, query: string): boolean => {
|
||||
const q = normalize(query);
|
||||
if (!q) return true;
|
||||
const hay = normalize([item.title, ...(item.keywords ?? [])].join(" "));
|
||||
return hay.includes(q);
|
||||
};
|
||||
125
components/settings/index/settingsCatalog.ts
Normal file
125
components/settings/index/settingsCatalog.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
export type SettingsTarget =
|
||||
| { type: "route"; route: string }
|
||||
| { type: "action"; action: "quickConnect" };
|
||||
|
||||
export interface SettingsEntry {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
target: SettingsTarget;
|
||||
/** extra search terms (English); the title is always searched too */
|
||||
keywords?: string[];
|
||||
/** when set, entry only shows on these platforms */
|
||||
platforms?: ("ios" | "android")[];
|
||||
}
|
||||
|
||||
export interface SettingsSectionDef {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
entries: SettingsEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for the Settings index: drives both rendering and search.
|
||||
*
|
||||
* EXTENSIBLE SHELL — to add a setting that lands from an in-flight PR, append one
|
||||
* entry to the right section (and, if it lives inside a sub-page, an entry in
|
||||
* settingsSearchIndex.ts). No screen rewrite needed. Reserved slots (add when the
|
||||
* PR merges): sleep timer #922, sync-play #1612, wake-on-LAN #1539 (advanced);
|
||||
* download location #1486/#1193, clear image cache #1589 (storage/downloads);
|
||||
* double-tap seek #1219/#1289, subtitle background #1543 (playback).
|
||||
*/
|
||||
export const SETTINGS_CATALOG: SettingsSectionDef[] = [
|
||||
{
|
||||
id: "playback",
|
||||
titleKey: "home.settings.categories.playback",
|
||||
entries: [
|
||||
{
|
||||
id: "playback-controls",
|
||||
titleKey: "home.settings.playback_controls.title",
|
||||
icon: "play",
|
||||
target: { type: "route", route: "/settings/playback-controls/page" },
|
||||
keywords: ["speed", "skip", "autoplay", "orientation"],
|
||||
},
|
||||
{
|
||||
id: "audio-subtitles",
|
||||
titleKey: "home.settings.audio_subtitles.title",
|
||||
icon: "chatbox-ellipses",
|
||||
target: { type: "route", route: "/settings/audio-subtitles/page" },
|
||||
keywords: ["subtitle", "audio", "language"],
|
||||
},
|
||||
{
|
||||
id: "music",
|
||||
titleKey: "home.settings.music.title",
|
||||
icon: "musical-notes",
|
||||
target: { type: "route", route: "/settings/music/page" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "personalization",
|
||||
titleKey: "home.settings.categories.personalization",
|
||||
entries: [
|
||||
{
|
||||
id: "appearance",
|
||||
titleKey: "home.settings.appearance.title",
|
||||
icon: "color-palette",
|
||||
target: { type: "route", route: "/settings/appearance/page" },
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
titleKey: "home.settings.notifications.title",
|
||||
icon: "notifications",
|
||||
target: { type: "route", route: "/settings/notifications/page" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "advanced",
|
||||
titleKey: "home.settings.categories.advanced",
|
||||
entries: [
|
||||
{
|
||||
id: "quick-connect",
|
||||
titleKey: "home.settings.quick_connect.quick_connect_title",
|
||||
icon: "key",
|
||||
target: { type: "action", action: "quickConnect" },
|
||||
},
|
||||
{
|
||||
id: "pair",
|
||||
titleKey: "pairing.pair_with_phone",
|
||||
icon: "phone-portrait",
|
||||
target: {
|
||||
type: "route",
|
||||
route: "/(auth)/(tabs)/(home)/companion-login",
|
||||
},
|
||||
platforms: ["android"],
|
||||
},
|
||||
{
|
||||
id: "plugins",
|
||||
titleKey: "home.settings.plugins.plugins_title",
|
||||
icon: "extension-puzzle",
|
||||
target: { type: "route", route: "/settings/plugins/page" },
|
||||
},
|
||||
{
|
||||
id: "network",
|
||||
titleKey: "home.settings.network.title",
|
||||
icon: "wifi",
|
||||
target: { type: "route", route: "/settings/network/page" },
|
||||
},
|
||||
{
|
||||
id: "logs",
|
||||
titleKey: "home.settings.logs.logs_title",
|
||||
icon: "document-text",
|
||||
target: { type: "route", route: "/settings/logs/page" },
|
||||
},
|
||||
{
|
||||
id: "intro",
|
||||
titleKey: "home.settings.intro.title",
|
||||
icon: "information-circle",
|
||||
target: { type: "route", route: "/settings/intro/page" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
266
components/settings/index/settingsSearchIndex.ts
Normal file
266
components/settings/index/settingsSearchIndex.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
export interface SearchableOption {
|
||||
titleKey: string;
|
||||
parentRoute: string;
|
||||
parentTitleKey: string;
|
||||
keywords?: string[];
|
||||
platforms?: ("ios" | "android")[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal options of sub-pages, for deep search ("index + internal settings").
|
||||
* Populated from a per-sub-page audit of every user-facing ListItem/dropdown row.
|
||||
* Every titleKey/parentTitleKey MUST be a real existing i18n key.
|
||||
*/
|
||||
export const SETTINGS_SEARCH_INDEX: SearchableOption[] = [
|
||||
// --- Playback & Controls -------------------------------------------------
|
||||
// MediaToggles
|
||||
{
|
||||
titleKey: "home.settings.media_controls.forward_skip_length",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["skip", "forward", "seconds"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.media_controls.rewind_length",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["rewind", "back", "seconds"],
|
||||
},
|
||||
// GestureControls
|
||||
{
|
||||
titleKey: "home.settings.gesture_controls.horizontal_swipe_skip",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["gesture", "swipe", "skip", "seek"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.gesture_controls.left_side_brightness",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["gesture", "swipe", "brightness", "left"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.gesture_controls.right_side_volume",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["gesture", "swipe", "volume", "right"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.gesture_controls.hide_volume_slider",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["volume", "slider", "hide", "gesture"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.gesture_controls.hide_brightness_slider",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["brightness", "slider", "hide", "gesture"],
|
||||
},
|
||||
// PlaybackControlsSettings (home.settings.other.*)
|
||||
{
|
||||
titleKey: "home.settings.other.video_orientation",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["orientation", "rotate", "landscape", "portrait"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.other.safe_area_in_controls",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["safe", "area", "notch", "controls", "inset"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.other.default_quality",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["quality", "bitrate", "resolution"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.other.default_playback_speed",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["playback", "speed", "rate"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.other.disable_haptic_feedback",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["haptic", "vibration", "feedback"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.other.auto_play_next_episode",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["autoplay", "auto", "next", "episode"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.other.max_auto_play_episode_count",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["autoplay", "auto", "episode", "count", "limit"],
|
||||
},
|
||||
// MpvBufferSettings
|
||||
{
|
||||
titleKey: "home.settings.buffer.cache_mode",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["cache", "buffer", "mode"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.buffer.buffer_duration",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["buffer", "duration", "cache", "seconds"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.buffer.max_cache_size",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["buffer", "cache", "size", "memory"],
|
||||
},
|
||||
// MpvVoSettings (Android only)
|
||||
{
|
||||
titleKey: "home.settings.vo_driver.vo_mode",
|
||||
parentRoute: "/settings/playback-controls/page",
|
||||
parentTitleKey: "home.settings.playback_controls.title",
|
||||
keywords: ["video", "output", "driver", "gpu", "mpv"],
|
||||
platforms: ["android"],
|
||||
},
|
||||
|
||||
// --- Audio & Subtitles ---------------------------------------------------
|
||||
// AudioToggles
|
||||
{
|
||||
titleKey: "home.settings.audio.set_audio_track",
|
||||
parentRoute: "/settings/audio-subtitles/page",
|
||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
||||
keywords: ["audio", "track", "remember", "previous"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.audio.audio_language",
|
||||
parentRoute: "/settings/audio-subtitles/page",
|
||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
||||
keywords: ["audio", "language", "default"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.audio.transcode_mode.title",
|
||||
parentRoute: "/settings/audio-subtitles/page",
|
||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
||||
keywords: ["audio", "transcode", "surround", "stereo", "passthrough"],
|
||||
},
|
||||
// SubtitleToggles
|
||||
{
|
||||
titleKey: "home.settings.subtitles.subtitle_language",
|
||||
parentRoute: "/settings/audio-subtitles/page",
|
||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
||||
keywords: ["subtitle", "caption", "language", "default"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.subtitles.subtitle_mode",
|
||||
parentRoute: "/settings/audio-subtitles/page",
|
||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
||||
keywords: ["subtitle", "caption", "mode", "forced"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.subtitles.set_subtitle_track",
|
||||
parentRoute: "/settings/audio-subtitles/page",
|
||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
||||
keywords: ["subtitle", "caption", "track", "remember", "previous"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.subtitles.subtitle_size",
|
||||
parentRoute: "/settings/audio-subtitles/page",
|
||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
||||
keywords: ["subtitle", "size", "caption", "scale", "font"],
|
||||
},
|
||||
|
||||
// --- Music ---------------------------------------------------------------
|
||||
{
|
||||
titleKey: "home.settings.music.prefer_downloaded",
|
||||
parentRoute: "/settings/music/page",
|
||||
parentTitleKey: "home.settings.music.title",
|
||||
keywords: ["music", "downloaded", "offline", "local"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.music.lookahead_enabled",
|
||||
parentRoute: "/settings/music/page",
|
||||
parentTitleKey: "home.settings.music.title",
|
||||
keywords: ["music", "lookahead", "cache", "prefetch"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.music.lookahead_count",
|
||||
parentRoute: "/settings/music/page",
|
||||
parentTitleKey: "home.settings.music.title",
|
||||
keywords: ["music", "lookahead", "cache", "count", "tracks"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.music.max_cache_size",
|
||||
parentRoute: "/settings/music/page",
|
||||
parentTitleKey: "home.settings.music.title",
|
||||
keywords: ["music", "cache", "size", "storage"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.storage.clear_music_cache",
|
||||
parentRoute: "/settings/music/page",
|
||||
parentTitleKey: "home.settings.music.title",
|
||||
keywords: ["music", "cache", "clear", "storage"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.storage.delete_all_downloaded_songs",
|
||||
parentRoute: "/settings/music/page",
|
||||
parentTitleKey: "home.settings.music.title",
|
||||
keywords: ["music", "downloaded", "delete", "songs", "storage"],
|
||||
},
|
||||
|
||||
// --- Appearance ----------------------------------------------------------
|
||||
{
|
||||
titleKey: "home.settings.other.show_custom_menu_links",
|
||||
parentRoute: "/settings/appearance/page",
|
||||
parentTitleKey: "home.settings.appearance.title",
|
||||
keywords: ["menu", "links", "custom", "navigation"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.appearance.merge_next_up_continue_watching",
|
||||
parentRoute: "/settings/appearance/page",
|
||||
parentTitleKey: "home.settings.appearance.title",
|
||||
keywords: ["continue", "watching", "next", "up", "merge", "home"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.other.hide_libraries",
|
||||
parentRoute: "/settings/appearance/page",
|
||||
parentTitleKey: "home.settings.appearance.title",
|
||||
keywords: ["hide", "libraries", "library", "home"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.appearance.hide_remote_session_button",
|
||||
parentRoute: "/settings/appearance/page",
|
||||
parentTitleKey: "home.settings.appearance.title",
|
||||
keywords: ["remote", "session", "button", "hide", "cast"],
|
||||
},
|
||||
|
||||
// --- Network -------------------------------------------------------------
|
||||
{
|
||||
titleKey: "home.settings.network.remote_url",
|
||||
parentRoute: "/settings/network/page",
|
||||
parentTitleKey: "home.settings.network.title",
|
||||
keywords: ["remote", "url", "server", "address"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.network.active_url",
|
||||
parentRoute: "/settings/network/page",
|
||||
parentTitleKey: "home.settings.network.title",
|
||||
keywords: ["active", "url", "server", "connection"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.network.auto_switch_enabled",
|
||||
parentRoute: "/settings/network/page",
|
||||
parentTitleKey: "home.settings.network.title",
|
||||
keywords: ["auto", "switch", "local", "wifi", "network"],
|
||||
},
|
||||
{
|
||||
titleKey: "home.settings.network.local_url",
|
||||
parentRoute: "/settings/network/page",
|
||||
parentTitleKey: "home.settings.network.title",
|
||||
keywords: ["local", "url", "lan", "server", "address"],
|
||||
},
|
||||
];
|
||||
46
components/settings/index/useSettingsSearch.ts
Normal file
46
components/settings/index/useSettingsSearch.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { matchesQuery } from "./searchFilter";
|
||||
import { SETTINGS_CATALOG, type SettingsTarget } from "./settingsCatalog";
|
||||
import { SETTINGS_SEARCH_INDEX } from "./settingsSearchIndex";
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
subtitle?: string;
|
||||
target: SettingsTarget;
|
||||
}
|
||||
|
||||
export const useSettingsSearch = (query: string): SearchResult[] => {
|
||||
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
|
||||
return useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
const results: SearchResult[] = [];
|
||||
for (const section of SETTINGS_CATALOG) {
|
||||
for (const e of section.entries) {
|
||||
if (e.platforms && !e.platforms.includes(os)) continue;
|
||||
const title = t(e.titleKey);
|
||||
if (matchesQuery({ title, keywords: e.keywords }, query)) {
|
||||
results.push({ id: e.id, title, icon: e.icon, target: e.target });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const o of SETTINGS_SEARCH_INDEX) {
|
||||
if (o.platforms && !o.platforms.includes(os)) continue;
|
||||
const title = t(o.titleKey);
|
||||
if (matchesQuery({ title, keywords: o.keywords }, query)) {
|
||||
results.push({
|
||||
id: `${o.parentRoute}#${o.titleKey}`,
|
||||
title,
|
||||
icon: "search",
|
||||
subtitle: t(o.parentTitleKey),
|
||||
target: { type: "route", route: o.parentRoute },
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}, [query, os]);
|
||||
};
|
||||
@@ -48,7 +48,6 @@ import type {
|
||||
TvDetails,
|
||||
} from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { writeErrorLog } from "@/utils/log";
|
||||
import { isVersionBelow } from "@/utils/serverUrl/semver";
|
||||
|
||||
interface SearchParams {
|
||||
query: string;
|
||||
@@ -142,13 +141,10 @@ export class JellyseerrApi {
|
||||
.then((response) => {
|
||||
const { status, headers, data } = response;
|
||||
if (inRange(status, 200, 299)) {
|
||||
if (data.version && isVersionBelow(data.version, "2.0.0")) {
|
||||
if (data.version < "2.0.0") {
|
||||
const error = t(
|
||||
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
||||
);
|
||||
writeErrorLog(
|
||||
`Jellyseerr version ${data.version} is below the required 2.0.0`,
|
||||
);
|
||||
toast.error(error);
|
||||
throw Error(error);
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type ResolveFailureReason,
|
||||
type ResolveOptions,
|
||||
type ResolveResult,
|
||||
resolveServerUrl,
|
||||
} from "@/utils/serverUrl/resolve";
|
||||
import type { ServerProbe } from "@/utils/serverUrl/types";
|
||||
|
||||
export type ServerUrlResolverState =
|
||||
| { status: "idle" }
|
||||
| { status: "resolving" }
|
||||
| { status: "ok"; resolvedUrl: string; meta?: Record<string, unknown> }
|
||||
| { status: "error"; reason: ResolveFailureReason };
|
||||
|
||||
/**
|
||||
* Stateful wrapper around `resolveServerUrl` for screens.
|
||||
*
|
||||
* `resolve(input)` cancels any in-flight resolution, drives the state machine
|
||||
* (idle → resolving → ok | error) and returns the raw result. Pass a stable
|
||||
* (module-level) probe; memoize `options` if you supply one.
|
||||
*/
|
||||
export function useServerUrlResolver(
|
||||
probe: ServerProbe,
|
||||
options?: ResolveOptions,
|
||||
) {
|
||||
const [state, setState] = useState<ServerUrlResolverState>({
|
||||
status: "idle",
|
||||
});
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const resolve = useCallback(
|
||||
async (input: string): Promise<ResolveResult> => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setState({ status: "resolving" });
|
||||
|
||||
const result = await resolveServerUrl(input, probe, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// Ignore results from a resolution that was superseded/cancelled.
|
||||
if (!controller.signal.aborted) {
|
||||
setState(
|
||||
result.ok
|
||||
? { status: "ok", resolvedUrl: result.url, meta: result.meta }
|
||||
: { status: "error", reason: result.reason },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[probe, options],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
setState({ status: "idle" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
return { ...state, resolve, reset };
|
||||
}
|
||||
@@ -213,7 +213,7 @@ public class MpvPlayerModule: Module {
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ class MpvPlayerView: ExpoView {
|
||||
let onProgress = EventDispatcher()
|
||||
let onError = EventDispatcher()
|
||||
let onTracksReady = EventDispatcher()
|
||||
let onPictureInPictureChange = EventDispatcher()
|
||||
|
||||
private var currentURL: URL?
|
||||
private var cachedPosition: Double = 0
|
||||
@@ -638,9 +637,6 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
print("PiP did start: \(didStartPictureInPicture)")
|
||||
// Ensure current time is synced when PiP starts
|
||||
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||
// Notify JS of the actual PiP active state. `didStartPictureInPicture`
|
||||
// is `false` when AVKit reports a failure to start, so reflect that.
|
||||
onPictureInPictureChange(["isActive": didStartPictureInPicture])
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||
@@ -659,9 +655,6 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
if _isZoomedToFill {
|
||||
displayLayer.videoGravity = .resizeAspectFill
|
||||
}
|
||||
// Notify JS that PiP has fully stopped so the controls overlay can
|
||||
// be re-mounted when the user returns to full screen.
|
||||
onPictureInPictureChange(["isActive": false])
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"expo-brightness": "~56.0.5",
|
||||
"expo-build-properties": "~56.0.15",
|
||||
"expo-camera": "~56.0.7",
|
||||
"expo-clipboard": "~56.0.3",
|
||||
"expo-constants": "~56.0.16",
|
||||
"expo-crypto": "~56.0.4",
|
||||
"expo-dev-client": "~56.0.16",
|
||||
@@ -124,8 +125,10 @@
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@react-native-community/cli": "20.1.3",
|
||||
"@react-native-tvos/config-tv": "0.1.6",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/node": "^24",
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type * as NotificationsType from "expo-notifications";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
// Conditionally import expo-notifications only on non-TV platforms
|
||||
const Notifications = Platform.isTV
|
||||
@@ -67,6 +68,14 @@ export async function sendDownloadNotification(
|
||||
): Promise<void> {
|
||||
if (Platform.isTV || !Notifications) return;
|
||||
|
||||
try {
|
||||
const raw = storage.getString("settings");
|
||||
const s = raw ? JSON.parse(raw) : {};
|
||||
if (s.notificationsEnabled === false || s.notifyDownloads === false) return;
|
||||
} catch {
|
||||
// ignore parse errors; fall through to sending (defaults are enabled)
|
||||
}
|
||||
|
||||
try {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
{
|
||||
"server_url": {
|
||||
"resolving": "Checking…",
|
||||
"resolved": "→ {{url}}",
|
||||
"connected": "Connected to {{url}}",
|
||||
"unreachable": "Server unreachable",
|
||||
"wrong_service": "Reachable, but not the expected server",
|
||||
"invalid_url": "Enter a valid address"
|
||||
},
|
||||
"login": {
|
||||
"username_required": "Username Is Required",
|
||||
"error_title": "Error",
|
||||
@@ -127,6 +119,9 @@
|
||||
"settings": {
|
||||
"settings_title": "Settings",
|
||||
"log_out_button": "Log Out",
|
||||
"search_placeholder": "Search settings",
|
||||
"search_results": "Results",
|
||||
"search_no_results": "No matching settings",
|
||||
"switch_user": {
|
||||
"title": "Switch User",
|
||||
"account": "Account",
|
||||
@@ -134,7 +129,25 @@
|
||||
"current": "current"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categories"
|
||||
"title": "Categories",
|
||||
"playback": "Playback",
|
||||
"personalization": "Personalization",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"disabled_title": "Notifications are off",
|
||||
"disabled_description": "Allow notifications to get alerts about your downloads and more.",
|
||||
"enable_button": "Enable notifications",
|
||||
"events_title": "Notify me about",
|
||||
"master": "Enable notifications",
|
||||
"downloads": "Download completed / failed"
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"copy_token": "Copy token",
|
||||
"copied": "Copied to clipboard",
|
||||
"copy_failed": "Couldn't copy to clipboard"
|
||||
},
|
||||
"playback_controls": {
|
||||
"title": "Playback & Controls"
|
||||
@@ -207,6 +220,10 @@
|
||||
"rewind_length": "Rewind Length",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"chromecast": {
|
||||
"title": "Chromecast",
|
||||
"enable_h265": "Enable H265 for Chromecast"
|
||||
},
|
||||
"buffer": {
|
||||
"title": "Buffer Settings",
|
||||
"cache_mode": "Cache Mode",
|
||||
|
||||
@@ -294,6 +294,12 @@ export type Settings = {
|
||||
openSubtitlesApiKey?: string;
|
||||
// TV-only: Inactivity timeout for auto-logout
|
||||
inactivityTimeout: InactivityTimeout;
|
||||
// Settings-redo additions (SP1)
|
||||
notificationsEnabled: boolean;
|
||||
notifyDownloads: boolean;
|
||||
defaultLandingTab: "(home)" | "(search)" | "(favorites)" | "(libraries)";
|
||||
downloadOnWifiOnly: boolean;
|
||||
cellularBitrate?: Bitrate;
|
||||
};
|
||||
|
||||
export interface Lockable<T> {
|
||||
@@ -395,6 +401,11 @@ export const defaultValues: Settings = {
|
||||
audioTranscodeMode: AudioTranscodeMode.Auto,
|
||||
// TV-only: Inactivity timeout (disabled by default)
|
||||
inactivityTimeout: InactivityTimeout.Disabled,
|
||||
notificationsEnabled: true,
|
||||
notifyDownloads: true,
|
||||
defaultLandingTab: "(home)",
|
||||
downloadOnWifiOnly: false,
|
||||
cellularBitrate: undefined,
|
||||
};
|
||||
|
||||
const loadSettings = (): Partial<Settings> => {
|
||||
|
||||
23
utils/expoUiBottomSheet.ts
Normal file
23
utils/expoUiBottomSheet.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Platform } from "react-native";
|
||||
|
||||
/**
|
||||
* TV-safe re-exports of `@expo/ui/community/bottom-sheet`.
|
||||
*
|
||||
* `@expo/ui` resolves its native bridge at module load via
|
||||
* `requireNativeModule('ExpoUI')`, which does not exist on tvOS — a static
|
||||
* top-level import would crash the whole expo-router route tree. We `require()`
|
||||
* it lazily and only off-TV; on TV the exports are undefined, which is fine
|
||||
* because every call site early-returns on `Platform.isTV`.
|
||||
*/
|
||||
type BottomSheetMod = typeof import("@expo/ui/community/bottom-sheet");
|
||||
|
||||
const mod: BottomSheetMod = Platform.isTV
|
||||
? ({} as BottomSheetMod)
|
||||
: (require("@expo/ui/community/bottom-sheet") as BottomSheetMod);
|
||||
|
||||
export const BottomSheetModal = mod.BottomSheetModal;
|
||||
export const BottomSheetView = mod.BottomSheetView;
|
||||
export const BottomSheetScrollView = mod.BottomSheetScrollView;
|
||||
export const BottomSheetTextInput = mod.BottomSheetTextInput;
|
||||
|
||||
export type { BottomSheetMethods } from "@expo/ui/community/bottom-sheet";
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Generic server-URL candidate generator.
|
||||
*
|
||||
* Turns loose user input (`media.uruk.dev`, `https://media.uruk.dev`,
|
||||
* `host:8096`, `http://10.0.0.5:3000/path`) into an ordered list of full URLs
|
||||
* to probe — https first, http as fallback — while preserving any explicit
|
||||
* port and path. Service-agnostic: unlike the Jellyfin SDK's `getAddressCandidates`
|
||||
* it adds no Jellyfin-specific ports, so it suits Jellyseerr/Streamystats/etc.
|
||||
*/
|
||||
|
||||
// scheme? host (port)? (path/query/hash)?
|
||||
const URL_RE = /^(?:(https?):\/\/)?([^/:\s?#]+)(?::(\d+))?([/?#].*)?$/i;
|
||||
|
||||
export interface ParsedServerInput {
|
||||
scheme?: "http" | "https";
|
||||
host: string;
|
||||
port?: string;
|
||||
/** Normalized path+query+hash, without a trailing slash; "" when none. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
function normalizePath(path?: string): string {
|
||||
if (!path || path === "/") return "";
|
||||
return path.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** Parse loose user input. Returns null when it can't be understood. */
|
||||
export function parseServerInput(input: string): ParsedServerInput | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const match = URL_RE.exec(trimmed);
|
||||
if (!match) return null;
|
||||
|
||||
const [, scheme, host, port, rawPath] = match;
|
||||
return {
|
||||
scheme: scheme ? (scheme.toLowerCase() as "http" | "https") : undefined,
|
||||
host: host.toLowerCase(),
|
||||
port,
|
||||
path: normalizePath(rawPath),
|
||||
};
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
scheme: "http" | "https",
|
||||
host: string,
|
||||
port: string | undefined,
|
||||
path: string,
|
||||
): string {
|
||||
return `${scheme}://${host}${port ? `:${port}` : ""}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered, de-duplicated candidate URLs for the given input.
|
||||
*
|
||||
* - Explicit scheme AND port → trusted as-is (single candidate).
|
||||
* - Otherwise https is tried before http (prefer secure), keeping any port/path.
|
||||
*
|
||||
* @returns [] when the input can't be parsed.
|
||||
*/
|
||||
export function getServerUrlCandidates(input: string): string[] {
|
||||
const parsed = parseServerInput(input);
|
||||
if (!parsed) return [];
|
||||
|
||||
const { scheme, host, port, path } = parsed;
|
||||
|
||||
// Fully specified: don't second-guess the user.
|
||||
if (scheme && port) return [buildUrl(scheme, host, port, path)];
|
||||
|
||||
// Secure-first; the typed scheme (if any) is still covered by this set.
|
||||
const candidates = (["https", "http"] as const).map((s) =>
|
||||
buildUrl(s, host, port, path),
|
||||
);
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export {
|
||||
getServerUrlCandidates,
|
||||
type ParsedServerInput,
|
||||
parseServerInput,
|
||||
} from "./candidates";
|
||||
export { jellyfinProbe } from "./probes/jellyfin";
|
||||
export { jellyseerrProbe } from "./probes/jellyseerr";
|
||||
export { reachabilityProbe } from "./probes/reachability";
|
||||
export {
|
||||
type ResolveFailureReason,
|
||||
type ResolveOptions,
|
||||
type ResolveResult,
|
||||
resolveServerUrl,
|
||||
} from "./resolve";
|
||||
export { isVersionBelow } from "./semver";
|
||||
export type { ServerProbe, ServerProbeOutcome } from "./types";
|
||||
@@ -1,24 +0,0 @@
|
||||
import axios from "axios";
|
||||
import type { ServerProbe } from "../types";
|
||||
|
||||
/** Public, unauthenticated Jellyfin endpoint; `ProductName` confirms the service. */
|
||||
const PRODUCT_NAME = "Jellyfin Server";
|
||||
|
||||
export const jellyfinProbe: ServerProbe = async (url, signal) => {
|
||||
try {
|
||||
const { status, data } = await axios.get(`${url}/System/Info/Public`, {
|
||||
signal,
|
||||
timeout: 8000, // backstop; the resolver aborts via signal first
|
||||
});
|
||||
|
||||
if (status < 200 || status >= 300) return { status: "unreachable" };
|
||||
if (data?.ProductName !== PRODUCT_NAME) return { status: "wrong-service" };
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
meta: { version: data?.Version, serverName: data?.ServerName },
|
||||
};
|
||||
} catch {
|
||||
return { status: "unreachable" };
|
||||
}
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import axios from "axios";
|
||||
import type { ServerProbe } from "../types";
|
||||
|
||||
/**
|
||||
* Probe for a Jellyseerr server. `/api/v1/status` is jellyseerr/overseerr
|
||||
* specific and unauthenticated, so it both proves reachability and confirms we
|
||||
* hit the right service. The minimum-version requirement is enforced at login
|
||||
* time (see JellyseerrApi.test) — not surfaced here, to keep the field UI clean.
|
||||
*/
|
||||
export const jellyseerrProbe: ServerProbe = async (url, signal) => {
|
||||
try {
|
||||
const { status, data } = await axios.get(`${url}/api/v1/status`, {
|
||||
signal,
|
||||
timeout: 8000, // backstop; the resolver aborts via signal first
|
||||
});
|
||||
|
||||
if (status < 200 || status >= 300) return { status: "unreachable" };
|
||||
|
||||
// A JSON body carrying version/commitTag identifies a real jellyseerr.
|
||||
const looksLikeJellyseerr =
|
||||
!!data &&
|
||||
typeof data === "object" &&
|
||||
(typeof data.version === "string" || "commitTag" in data);
|
||||
if (!looksLikeJellyseerr) return { status: "wrong-service" };
|
||||
|
||||
return { status: "ok", meta: { version: data.version } };
|
||||
} catch {
|
||||
return { status: "unreachable" };
|
||||
}
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import axios from "axios";
|
||||
import type { ServerProbe } from "../types";
|
||||
|
||||
/**
|
||||
* Minimal probe for services without a known/unauthenticated health endpoint
|
||||
* (e.g. Marlin Search, Streamystats). Any HTTP response — even 4xx — proves the
|
||||
* host is up and speaking HTTP at this protocol/port, which is enough to pick
|
||||
* https vs http. It cannot detect a "wrong service".
|
||||
*/
|
||||
export const reachabilityProbe: ServerProbe = async (url, signal) => {
|
||||
try {
|
||||
await axios.get(url, {
|
||||
signal,
|
||||
timeout: 8000,
|
||||
validateStatus: () => true, // any status = the server answered
|
||||
});
|
||||
return { status: "ok" };
|
||||
} catch (error) {
|
||||
// A delivered response that still threw counts as reachable.
|
||||
if ((error as { response?: unknown })?.response) return { status: "ok" };
|
||||
return { status: "unreachable" };
|
||||
}
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
import { getServerUrlCandidates } from "./candidates";
|
||||
import type { ServerProbe, ServerProbeOutcome } from "./types";
|
||||
|
||||
export type ResolveFailureReason =
|
||||
| "empty"
|
||||
| "invalid"
|
||||
| "wrong-service"
|
||||
| "unreachable";
|
||||
|
||||
export type ResolveResult =
|
||||
| { ok: true; url: string; meta?: Record<string, unknown> }
|
||||
| { ok: false; reason: ResolveFailureReason };
|
||||
|
||||
export interface ResolveOptions {
|
||||
/** Per-candidate probe timeout in ms. Default 5000. */
|
||||
timeoutMs?: number;
|
||||
/** Abort the whole resolution (cancels every in-flight probe). */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// Order in which to surface a failure when no candidate validated:
|
||||
// the more specific/actionable the reason, the earlier it is reported.
|
||||
const FAILURE_PRIORITY = [
|
||||
"wrong-service",
|
||||
"unreachable",
|
||||
] as const satisfies ReadonlyArray<ResolveFailureReason>;
|
||||
|
||||
/**
|
||||
* Resolve loose user input to a single working, canonical server URL.
|
||||
*
|
||||
* Generates candidates (https-first), probes them in parallel with a per-candidate
|
||||
* timeout, and returns the first candidate (in preference order) the probe
|
||||
* accepted. When none work, the most actionable failure is returned.
|
||||
*/
|
||||
export async function resolveServerUrl(
|
||||
input: string,
|
||||
probe: ServerProbe,
|
||||
options: ResolveOptions = {},
|
||||
): Promise<ResolveResult> {
|
||||
const { timeoutMs = 5000, signal } = options;
|
||||
|
||||
if (!input.trim()) return { ok: false, reason: "empty" };
|
||||
|
||||
const candidates = getServerUrlCandidates(input);
|
||||
if (candidates.length === 0) return { ok: false, reason: "invalid" };
|
||||
|
||||
const outcomes = await Promise.all(
|
||||
candidates.map((url) => runProbe(url, probe, timeoutMs, signal)),
|
||||
);
|
||||
|
||||
// Prefer the first candidate (https-first) that validated.
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const outcome = outcomes[i];
|
||||
if (outcome.status === "ok") {
|
||||
return { ok: true, url: candidates[i], meta: outcome.meta };
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing validated: report the most useful failure.
|
||||
for (const reason of FAILURE_PRIORITY) {
|
||||
const hit = outcomes.find((outcome) => outcome.status === reason);
|
||||
if (hit) {
|
||||
return { ok: false, reason };
|
||||
}
|
||||
}
|
||||
return { ok: false, reason: "unreachable" };
|
||||
}
|
||||
|
||||
async function runProbe(
|
||||
url: string,
|
||||
probe: ServerProbe,
|
||||
timeoutMs: number,
|
||||
parentSignal?: AbortSignal,
|
||||
): Promise<ServerProbeOutcome> {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
parentSignal?.addEventListener("abort", abort);
|
||||
const timer = setTimeout(abort, timeoutMs);
|
||||
|
||||
try {
|
||||
return await probe(url, controller.signal);
|
||||
} catch {
|
||||
return { status: "unreachable" };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
parentSignal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Strict numeric "below" comparison for dotted versions.
|
||||
*
|
||||
* Avoids the string-comparison bug (`"1.9.9" < "2.0.0"` works by luck but
|
||||
* `"2.10.0" < "2.0.0"` is wrongly true). Non-numeric/pre-release suffixes on a
|
||||
* segment are ignored (e.g. `2.0.0-beta` → 2.0.0).
|
||||
*/
|
||||
export function isVersionBelow(version: string, minimum: string): boolean {
|
||||
const parse = (v: string) =>
|
||||
v.split(".").map((segment) => Number.parseInt(segment, 10) || 0);
|
||||
|
||||
const a = parse(version);
|
||||
const b = parse(minimum);
|
||||
const length = Math.max(a.length, b.length);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const x = a[i] ?? 0;
|
||||
const y = b[i] ?? 0;
|
||||
if (x !== y) return x < y;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/** Result of probing a single candidate URL for a specific service. */
|
||||
export type ServerProbeOutcome =
|
||||
| { status: "ok"; meta?: Record<string, unknown> }
|
||||
| { status: "wrong-service" }
|
||||
| { status: "unreachable" };
|
||||
|
||||
/**
|
||||
* Validates one fully-qualified candidate URL for a given service.
|
||||
* Implementations must resolve (never reject) — map errors to "unreachable".
|
||||
* The provided signal is aborted on timeout or cancellation.
|
||||
*/
|
||||
export type ServerProbe = (
|
||||
url: string,
|
||||
signal: AbortSignal,
|
||||
) => Promise<ServerProbeOutcome>;
|
||||
Reference in New Issue
Block a user