Compare commits

..

1 Commits

Author SHA1 Message Date
Lance Chant
cd7bc201c0 fix player reporting when exiting and app splash load
Fixed an issue where the playback would continue when the player was
exited
Fixed an issue where the splash screen would take forever to load when
server is not reachable tested with 192.0.2.1 documentation IP (RFC 5737) — packets to it are silently dropped by routers

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-03 09:51:22 +02:00
33 changed files with 507 additions and 1373 deletions

View File

@@ -243,28 +243,6 @@ 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} />
))}

View File

@@ -1,44 +1,38 @@
import { useNavigation } from "expo-router";
import { t } from "i18next";
import { useEffect, useRef, useState } from "react";
import { useAtom } from "jotai";
import { useEffect } 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 { 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 { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { UserInfo } from "@/components/settings/UserInfo";
import useRouter from "@/hooks/useAppRouter";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { useJellyfin, userAtom } 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;
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => logout()}>
<TouchableOpacity
onPress={() => {
logout();
}}
>
<Text className='text-red-600 px-2'>
{t("home.settings.log_out_button")}
</Text>
@@ -47,95 +41,98 @@ 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,
}}
>
{!searching && (
<SettingsHero
onPress={() => router.push("/settings/account/page" as any)}
/>
)}
<SettingsSearchBar value={query} onChange={setQuery} />
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<View className='mb-4'>
<UserInfo />
</View>
{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}
<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'
/>
))
)}
</SettingsSection>
) : (
<>
<View className='mx-3 mb-5'>
<AppLanguageSelector />
</ListGroup>
</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>
</>
)}
)}
<QuickConnectSheet ref={quickConnectRef} />
<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>
</ScrollView>
);
}
export default function settings() {
// Use TV settings component on TV platforms
if (Platform.isTV && SettingsTV) {
return <SettingsTV />;
}
return <SettingsMobile />;
}

View File

@@ -1,60 +0,0 @@
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>
);
}

View File

@@ -1,68 +0,0 @@
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>
);
}

View File

@@ -20,16 +20,18 @@ export default function PlaybackControlsPage() {
}}
>
<View
className='p-4'
style={{ gap: 16, paddingTop: Platform.OS === "android" ? 10 : 0 }}
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<MediaProvider>
<MediaToggles />
<GestureControls />
<PlaybackControlsSettings />
<MpvBufferSettings />
<MpvVoSettings />
</MediaProvider>
<View className='mb-4'>
<MediaProvider>
<MediaToggles className='mb-4' />
<GestureControls className='mb-4' />
<PlaybackControlsSettings />
<MpvBufferSettings />
<MpvVoSettings />
</MediaProvider>
</View>
{!Platform.isTV && <ChromecastSettings />}
</View>
</ScrollView>

View File

@@ -439,21 +439,15 @@ export default function DirectPlayerPage() {
if (!item?.Id || !stream?.sessionId || offline || !api) return;
const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api).onPlaybackStopped({
itemId: item.Id,
mediaSourceId: mediaSourceId,
positionTicks: currentTimeInTicks,
playSessionId: stream.sessionId,
await getPlaystateApi(api).reportPlaybackStopped({
playbackStopInfo: {
ItemId: item.Id,
MediaSourceId: mediaSourceId,
PositionTicks: currentTimeInTicks,
PlaySessionId: stream.sessionId,
},
});
}, [
api,
item,
mediaSourceId,
stream,
progress,
offline,
revalidateProgressCache,
]);
}, [api, item, mediaSourceId, stream, progress, offline]);
const stop = useCallback(() => {
// Update URL with final playback position before stopping
@@ -471,9 +465,10 @@ export default function DirectPlayerPage() {
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
reportPlaybackStopped();
beforeRemoveListener();
};
}, [navigation, stop]);
}, [navigation, stop, reportPlaybackStopped]);
const currentPlayStateInfo = useCallback(():
| PlaybackProgressInfo

View File

@@ -31,7 +31,6 @@
"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",
@@ -104,10 +103,8 @@
"@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",
@@ -590,8 +587,6 @@
"@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=="],
@@ -608,7 +603,7 @@
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
"@types/node": ["@types/node@24.13.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg=="],
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
@@ -748,8 +743,6 @@
"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=="],
@@ -962,8 +955,6 @@
"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=="],
@@ -1836,7 +1827,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@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
@@ -2040,8 +2031,6 @@
"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=="],
@@ -2140,8 +2129,6 @@
"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=="],
@@ -2260,8 +2247,6 @@
"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=="],
@@ -2318,8 +2303,6 @@
"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=="],

View File

@@ -34,13 +34,12 @@ 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 ${hasSubtitle ? "min-h-[48px] py-2" : "h-[48px]"} px-4 ${isDisabled ? "opacity-50" : ""}`}
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
{...(viewProps as any)}
>
<ListItemContent
@@ -59,7 +58,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
);
return (
<View
className={`flex flex-row items-center justify-between bg-neutral-900 ${hasSubtitle ? "min-h-[48px] py-2" : "h-[48px]"} px-4 ${isDisabled ? "opacity-50" : ""}`}
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
{...viewProps}
>
<ListItemContent

View File

@@ -1,22 +1,21 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
import { Switch, View } from "react-native";
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={t("home.settings.chromecast.title")}>
<SettingsSwitchRow
title={t("home.settings.chromecast.enable_h265")}
value={settings.enableH265ForChromecast}
onValueChange={(enableH265ForChromecast) =>
updateSettings({ enableH265ForChromecast })
}
/>
<ListGroup title={"Chromecast"}>
<ListItem title={"Enable H265 for Chromecast"}>
<Switch
value={settings.enableH265ForChromecast}
onValueChange={(enableH265ForChromecast) =>
updateSettings({ enableH265ForChromecast })
}
/>
</ListItem>
</ListGroup>
</View>
);

View File

@@ -2,10 +2,11 @@ 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 {}
@@ -31,65 +32,85 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
<ListGroup
title={t("home.settings.gesture_controls.gesture_controls_title")}
>
<SettingsSwitchRow
<ListItem
title={t("home.settings.gesture_controls.horizontal_swipe_skip")}
subtitle={t(
"home.settings.gesture_controls.horizontal_swipe_skip_description",
)}
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
value={settings.enableHorizontalSwipeSkip}
onValueChange={(enableHorizontalSwipeSkip) =>
updateSettings({ enableHorizontalSwipeSkip })
}
/>
>
<Switch
value={settings.enableHorizontalSwipeSkip}
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
onValueChange={(enableHorizontalSwipeSkip) =>
updateSettings({ enableHorizontalSwipeSkip })
}
/>
</ListItem>
<SettingsSwitchRow
<ListItem
title={t("home.settings.gesture_controls.left_side_brightness")}
subtitle={t(
"home.settings.gesture_controls.left_side_brightness_description",
)}
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
value={settings.enableLeftSideBrightnessSwipe}
onValueChange={(enableLeftSideBrightnessSwipe) =>
updateSettings({ enableLeftSideBrightnessSwipe })
}
/>
>
<Switch
value={settings.enableLeftSideBrightnessSwipe}
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
onValueChange={(enableLeftSideBrightnessSwipe) =>
updateSettings({ enableLeftSideBrightnessSwipe })
}
/>
</ListItem>
<SettingsSwitchRow
<ListItem
title={t("home.settings.gesture_controls.right_side_volume")}
subtitle={t(
"home.settings.gesture_controls.right_side_volume_description",
)}
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
value={settings.enableRightSideVolumeSwipe}
onValueChange={(enableRightSideVolumeSwipe) =>
updateSettings({ enableRightSideVolumeSwipe })
}
/>
>
<Switch
value={settings.enableRightSideVolumeSwipe}
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
onValueChange={(enableRightSideVolumeSwipe) =>
updateSettings({ enableRightSideVolumeSwipe })
}
/>
</ListItem>
<SettingsSwitchRow
<ListItem
title={t("home.settings.gesture_controls.hide_volume_slider")}
subtitle={t(
"home.settings.gesture_controls.hide_volume_slider_description",
)}
disabled={pluginSettings?.hideVolumeSlider?.locked}
value={settings.hideVolumeSlider}
onValueChange={(hideVolumeSlider) =>
updateSettings({ hideVolumeSlider })
}
/>
>
<Switch
value={settings.hideVolumeSlider}
disabled={pluginSettings?.hideVolumeSlider?.locked}
onValueChange={(hideVolumeSlider) =>
updateSettings({ hideVolumeSlider })
}
/>
</ListItem>
<SettingsSwitchRow
<ListItem
title={t("home.settings.gesture_controls.hide_brightness_slider")}
subtitle={t(
"home.settings.gesture_controls.hide_brightness_slider_description",
)}
disabled={pluginSettings?.hideBrightnessSlider?.locked}
value={settings.hideBrightnessSlider}
onValueChange={(hideBrightnessSlider) =>
updateSettings({ hideBrightnessSlider })
}
/>
>
<Switch
value={settings.hideBrightnessSlider}
disabled={pluginSettings?.hideBrightnessSlider?.locked}
onValueChange={(hideBrightnessSlider) =>
updateSettings({ hideBrightnessSlider })
}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);

View File

@@ -2,10 +2,11 @@ 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 {}
@@ -26,27 +27,35 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
return (
<DisabledSetting disabled={disabled} {...props}>
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
<SettingsStepperRow
<ListItem
title={t("home.settings.media_controls.forward_skip_length")}
disabled={pluginSettings?.forwardSkipTime?.locked}
value={settings.forwardSkipTime}
step={5}
appendValue={t("home.settings.media_controls.seconds_unit")}
min={0}
max={60}
onUpdate={(forwardSkipTime) => updateSettings({ forwardSkipTime })}
/>
>
<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>
<SettingsStepperRow
<ListItem
title={t("home.settings.media_controls.rewind_length")}
disabled={pluginSettings?.rewindSkipTime?.locked}
value={settings.rewindSkipTime}
step={5}
appendValue={t("home.settings.media_controls.seconds_unit")}
min={0}
max={60}
onUpdate={(rewindSkipTime) => updateSettings({ rewindSkipTime })}
/>
>
<Stepper
value={settings.rewindSkipTime}
disabled={pluginSettings?.rewindSkipTime?.locked}
step={5}
appendValue={t("home.settings.media_controls.seconds_unit")}
min={0}
max={60}
onUpdate={(rewindSkipTime) => updateSettings({ rewindSkipTime })}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);

View File

@@ -1,10 +1,14 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
import { SettingsStepperRow } from "@/components/settings/index/SettingsStepperRow";
import { View } from "react-native";
import { Stepper } from "@/components/inputs/Stepper";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
{ key: "home.settings.buffer.cache_auto", value: "auto" },
@@ -41,43 +45,56 @@ export const MpvBufferSettings: React.FC = () => {
if (!settings) return null;
return (
<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")}
/>
<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>
<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.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.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_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_backward_cache")}
value={settings.mpvDemuxerMaxBackBytes ?? 50}
step={25}
min={25}
max={200}
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBackBytes: 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>
</ListGroup>
);
};

View File

@@ -1,10 +1,13 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
import { Platform, View } from "react-native";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [
{ key: "home.settings.vo_driver.gpu_next", value: "gpu-next" },
@@ -43,13 +46,21 @@ export const MpvVoSettings: React.FC = () => {
if (!settings) return null;
return (
<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 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>
);
};

View File

@@ -1,15 +1,18 @@
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();
@@ -113,77 +116,141 @@ export const PlaybackControlsSettings: React.FC = () => {
return (
<DisabledSetting disabled={disabled}>
<ListGroup title={t("home.settings.other.other_title")} className=''>
<SettingsSelectRow
<ListItem
title={t("home.settings.other.video_orientation")}
disabled={pluginSettings?.defaultVideoOrientation?.locked}
valueLabel={
t(
orientationTranslations[
settings.defaultVideoOrientation as keyof typeof orientationTranslations
],
) || "Unknown Orientation"
}
groups={orientationOptions}
dropdownTitle={t("home.settings.other.orientation")}
/>
>
<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>
<SettingsSwitchRow
<ListItem
title={t("home.settings.other.safe_area_in_controls")}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
value={settings.safeAreaInControlsEnabled}
onValueChange={(value) =>
updateSettings({ safeAreaInControlsEnabled: value })
}
/>
>
<Switch
value={settings.safeAreaInControlsEnabled}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
onValueChange={(value) =>
updateSettings({ safeAreaInControlsEnabled: value })
}
/>
</ListItem>
<SettingsSelectRow
<ListItem
title={t("home.settings.other.default_quality")}
disabled={pluginSettings?.defaultBitrate?.locked}
valueLabel={settings.defaultBitrate?.key}
groups={bitrateOptions}
dropdownTitle={t("home.settings.other.default_quality")}
/>
>
<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>
<SettingsSelectRow
<ListItem
title={t("home.settings.other.default_playback_speed")}
disabled={pluginSettings?.defaultPlaybackSpeed?.locked}
valueLabel={
PLAYBACK_SPEEDS.find(
(s) => s.value === settings.defaultPlaybackSpeed,
)?.label ?? "1x"
}
groups={playbackSpeedOptions}
dropdownTitle={t("home.settings.other.default_playback_speed")}
/>
>
<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>
<SettingsSwitchRow
<ListItem
title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked}
value={settings.disableHapticFeedback}
onValueChange={(disableHapticFeedback) =>
updateSettings({ disableHapticFeedback })
}
/>
>
<Switch
value={settings.disableHapticFeedback}
disabled={pluginSettings?.disableHapticFeedback?.locked}
onValueChange={(disableHapticFeedback) =>
updateSettings({ disableHapticFeedback })
}
/>
</ListItem>
<SettingsSwitchRow
<ListItem
title={t("home.settings.other.auto_play_next_episode")}
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
value={settings.autoPlayNextEpisode}
onValueChange={(autoPlayNextEpisode) =>
updateSettings({ autoPlayNextEpisode })
}
/>
>
<Switch
value={settings.autoPlayNextEpisode}
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
onValueChange={(autoPlayNextEpisode) =>
updateSettings({ autoPlayNextEpisode })
}
/>
</ListItem>
<SettingsSelectRow
<ListItem
title={t("home.settings.other.max_auto_play_episode_count")}
disabled={
!settings.autoPlayNextEpisode ||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
}
valueLabel={t(settings?.maxAutoPlayEpisodeCount.key)}
groups={autoPlayEpisodeOptions}
dropdownTitle={t("home.settings.other.max_auto_play_episode_count")}
/>
>
<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>
</ListGroup>
</DisabledSetting>
);

View File

@@ -1,57 +1,54 @@
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
type BottomSheetMethods,
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
} 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 { useTranslation } from "react-i18next";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Button } from "../Button";
import { Text } from "../common/Text";
import { PinInput } from "../inputs/PinInput";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export type QuickConnectSheetRef = { present: () => void };
interface Props extends ViewProps {}
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();
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";
useImperativeHandle(
ref,
() => ({
present: () => {
setQuickConnectCode("");
modalRef.current?.present();
},
}),
[],
);
const { t } = useTranslation();
const authorizeQuickConnect = useCallback(async () => {
if (!quickConnectCode) return;
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const authorizeQuickConnect = useCallback(async () => {
if (quickConnectCode) {
try {
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
code: quickConnectCode,
@@ -64,7 +61,7 @@ export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
t("home.settings.quick_connect.quick_connect_autorized"),
);
setQuickConnectCode(undefined);
modalRef.current?.close();
bottomSheetModalRef?.current?.close();
} else {
errorHapticFeedback();
Alert.alert(
@@ -79,26 +76,39 @@ export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
t("home.settings.quick_connect.invalid_code"),
);
}
}, [
api,
user,
quickConnectCode,
t,
successHapticFeedback,
errorHapticFeedback,
]);
}
}, [api, user, quickConnectCode]);
if (isTv) return null;
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>
return (
<BottomSheetModal
ref={modalRef}
enablePanDownToClose
ref={bottomSheetModalRef}
snapPoints={snapPoints}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
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'>
@@ -132,8 +142,6 @@ export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
</View>
</BottomSheetView>
</BottomSheetModal>
);
},
);
QuickConnectSheet.displayName = "QuickConnectSheet";
</View>
);
};

View File

@@ -1,81 +0,0 @@
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>
);
};

View File

@@ -1,62 +0,0 @@
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>
);
};

View File

@@ -1,28 +0,0 @@
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>
);

View File

@@ -1,19 +0,0 @@
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>
);

View File

@@ -1,38 +0,0 @@
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>
);

View File

@@ -1,39 +0,0 @@
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>
);

View File

@@ -1,23 +0,0 @@
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>
);

View File

@@ -1,22 +0,0 @@
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);
});

View File

@@ -1,14 +0,0 @@
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);
};

View File

@@ -1,125 +0,0 @@
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" },
},
],
},
];

View File

@@ -1,266 +0,0 @@
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"],
},
];

View File

@@ -1,46 +0,0 @@
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]);
};

View File

@@ -52,7 +52,6 @@
"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",
@@ -125,10 +124,8 @@
"@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",

View File

@@ -2,7 +2,6 @@ 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
@@ -68,14 +67,6 @@ 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: {

View File

@@ -619,44 +619,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(storedUser);
}
const response = await getUserApi(apiInstance).getCurrentUser();
setUser(response.data);
// Dismiss splash screen with cached data immediately,
// fetch fresh user data in the background
setInitialLoaded(true);
// Migrate current session to secure storage if not already saved
if (storedUser?.Id && storedUser?.Name) {
const existingCredential = await getAccountCredential(
serverUrl,
storedUser.Id,
);
if (!existingCredential) {
await saveAccountCredential({
try {
const response = await getUserApi(apiInstance).getCurrentUser();
setUser(response.data);
// Migrate current session to secure storage if not already saved
if (storedUser?.Id && storedUser?.Name) {
const existingCredential = await getAccountCredential(
serverUrl,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
} else if (
response.data.PrimaryImageTag !==
existingCredential.primaryImageTag
) {
// Update image tag if it has changed
addAccountToServer(serverUrl, existingCredential.serverName, {
userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
storedUser.Id,
);
if (!existingCredential) {
await saveAccountCredential({
serverUrl,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
} else if (
response.data.PrimaryImageTag !==
existingCredential.primaryImageTag
) {
// Update image tag if it has changed
addAccountToServer(serverUrl, existingCredential.serverName, {
userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
}
} catch (e) {
// Background fetch failed — app already rendered with cached data
console.warn("Background user fetch failed, using cached data:", e);
}
} else {
setInitialLoaded(true);
}
} catch (e) {
console.error(e);
} finally {
setInitialLoaded(true);
}
};

View File

@@ -119,9 +119,6 @@
"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",
@@ -129,25 +126,7 @@
"current": "current"
},
"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"
"title": "Categories"
},
"playback_controls": {
"title": "Playback & Controls"
@@ -220,10 +199,6 @@
"rewind_length": "Rewind Length",
"seconds_unit": "s"
},
"chromecast": {
"title": "Chromecast",
"enable_h265": "Enable H265 for Chromecast"
},
"buffer": {
"title": "Buffer Settings",
"cache_mode": "Cache Mode",

View File

@@ -294,12 +294,6 @@ 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> {
@@ -401,11 +395,6 @@ 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> => {

View File

@@ -1,23 +0,0 @@
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";