diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index e0893875e..9729fafd9 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -1,16 +1,19 @@ import { useNavigation } from "expo-router"; import { t } from "i18next"; -import { useEffect, useRef } 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 { 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 SettingsEntry, + type SettingsTarget, } from "@/components/settings/index/settingsCatalog"; +import { useSettingsSearch } from "@/components/settings/index/useSettingsSearch"; import { QuickConnectSheet, type QuickConnectSheetRef, @@ -19,7 +22,6 @@ import { StorageSettings } from "@/components/settings/StorageSettings"; import useRouter from "@/hooks/useAppRouter"; import { useJellyfin } from "@/providers/JellyfinProvider"; -// TV-specific settings component const SettingsTV = Platform.isTV ? require("./settings.tv").default : null; function SettingsMobile() { @@ -28,7 +30,10 @@ function SettingsMobile() { const { logout } = useJellyfin(); const navigation = useNavigation(); const quickConnectRef = useRef(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({ @@ -42,19 +47,20 @@ function SettingsMobile() { }); }, []); - const handleEntry = (entry: SettingsEntry) => { - if (entry.target.type === "action") { - if (entry.target.action === "quickConnect") { + const handleTarget = (target: SettingsTarget) => { + if (target.type === "action") { + if (target.action === "quickConnect") { quickConnectRef.current?.present(); } return; } - router.push(entry.target.route as any); + router.push(target.route as any); }; return ( - - - + {!searching && ( + router.push("/settings/account/page" as any)} + /> + )} + - {SETTINGS_CATALOG.map((section) => { - const entries = section.entries.filter( - (e) => !e.platforms || e.platforms.includes(os), - ); - if (entries.length === 0) return null; - return ( - - {entries.map((e, i) => ( + {searching ? ( + + {results.length === 0 ? ( + + + {t("home.settings.search_no_results")} + + + ) : ( + results.map((r, i) => ( handleEntry(e)} - isLast={i === entries.length - 1} + key={r.id} + title={r.title} + icon={r.icon} + value={r.subtitle} + onPress={() => handleTarget(r.target)} + isLast={i === results.length - 1} /> - ))} + )) + )} + + ) : ( + <> + + + + {SETTINGS_CATALOG.map((section) => { + const entries = section.entries.filter( + (e) => !e.platforms || e.platforms.includes(os), + ); + if (entries.length === 0) return null; + return ( + + {entries.map((e, i) => ( + handleTarget(e.target)} + isLast={i === entries.length - 1} + /> + ))} + + ); + })} + + + + - ); - })} - - - - - - + + )} diff --git a/components/settings/index/SettingsHero.tsx b/components/settings/index/SettingsHero.tsx new file mode 100644 index 000000000..9e0dfc3d9 --- /dev/null +++ b/components/settings/index/SettingsHero.tsx @@ -0,0 +1,76 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { useAtom } 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"; + +export const SettingsHero: React.FC<{ onPress: () => void }> = ({ + onPress, +}) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const connected = Boolean(api && user); + const imageUrl = + api && user?.Id && user.PrimaryImageTag + ? `${api.basePath}/Users/${user.Id}/Images/Primary?tag=${user.PrimaryImageTag}&quality=90` + : undefined; + const host = api?.basePath?.replace(/^https?:\/\//, ""); + + return ( + + + + {imageUrl ? ( + + ) : ( + + + {(user?.Name?.[0] ?? "?").toUpperCase()} + + + )} + + + {user?.Name ?? ""} + + + + + {host} + + + + + + + + ); +}; diff --git a/components/settings/index/SettingsSearchBar.tsx b/components/settings/index/SettingsSearchBar.tsx new file mode 100644 index 000000000..e1c289318 --- /dev/null +++ b/components/settings/index/SettingsSearchBar.tsx @@ -0,0 +1,23 @@ +import { Ionicons } from "@expo/vector-icons"; +import { t } from "i18next"; +import type React from "react"; +import { TextInput, View } from "react-native"; + +export const SettingsSearchBar: React.FC<{ + value: string; + onChange: (v: string) => void; +}> = ({ value, onChange }) => ( + + + + +); diff --git a/components/settings/index/settingsSearchIndex.ts b/components/settings/index/settingsSearchIndex.ts new file mode 100644 index 000000000..6db8eb5df --- /dev/null +++ b/components/settings/index/settingsSearchIndex.ts @@ -0,0 +1,38 @@ +export interface SearchableOption { + titleKey: string; + parentRoute: string; + parentTitleKey: string; + keywords?: string[]; +} + +/** + * Internal options of sub-pages, for deep search ("index + internal settings"). + * SEED LIST — expand by auditing each sub-page component. Every titleKey/ + * parentTitleKey MUST be a real existing i18n key. + */ +export const SETTINGS_SEARCH_INDEX: SearchableOption[] = [ + { + titleKey: "home.settings.media_controls.forward_skip_length", + parentRoute: "/settings/playback-controls/page", + parentTitleKey: "home.settings.playback_controls.title", + keywords: ["skip", "forward"], + }, + { + titleKey: "home.settings.media_controls.rewind_length", + parentRoute: "/settings/playback-controls/page", + parentTitleKey: "home.settings.playback_controls.title", + keywords: ["rewind"], + }, + { + titleKey: "home.settings.subtitles.subtitle_size", + parentRoute: "/settings/audio-subtitles/page", + parentTitleKey: "home.settings.audio_subtitles.title", + keywords: ["subtitle", "size"], + }, + { + titleKey: "home.settings.gesture_controls.gesture_controls_title", + parentRoute: "/settings/playback-controls/page", + parentTitleKey: "home.settings.playback_controls.title", + keywords: ["gesture", "swipe", "brightness", "volume"], + }, +]; diff --git a/components/settings/index/useSettingsSearch.ts b/components/settings/index/useSettingsSearch.ts new file mode 100644 index 000000000..423a178ca --- /dev/null +++ b/components/settings/index/useSettingsSearch.ts @@ -0,0 +1,50 @@ +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; + if ( + matchesQuery({ title: t(e.titleKey), keywords: e.keywords }, query) + ) { + results.push({ + id: e.id, + title: t(e.titleKey), + icon: e.icon, + target: e.target, + }); + } + } + } + for (const o of SETTINGS_SEARCH_INDEX) { + if (matchesQuery({ title: t(o.titleKey), keywords: o.keywords }, query)) { + results.push({ + id: `${o.parentRoute}#${o.titleKey}`, + title: t(o.titleKey), + icon: "search", + subtitle: t(o.parentTitleKey), + target: { type: "route", route: o.parentRoute }, + }); + } + } + return results; + }, [query, os]); +}; diff --git a/translations/en.json b/translations/en.json index b8e64df03..ef2124c7b 100644 --- a/translations/en.json +++ b/translations/en.json @@ -119,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",