mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-04 04:58:30 +01:00
feat(settings): add profile hero and settings search
This commit is contained in:
@@ -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<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({
|
||||
@@ -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 (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
keyboardShouldPersistTaps='handled'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
@@ -62,35 +68,65 @@ function SettingsMobile() {
|
||||
paddingBottom: 32,
|
||||
}}
|
||||
>
|
||||
<View className='mx-3 mb-5'>
|
||||
<AppLanguageSelector />
|
||||
</View>
|
||||
{!searching && (
|
||||
<SettingsHero
|
||||
onPress={() => router.push("/settings/account/page" as any)}
|
||||
/>
|
||||
)}
|
||||
<SettingsSearchBar value={query} onChange={setQuery} />
|
||||
|
||||
{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) => (
|
||||
{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={e.id}
|
||||
title={t(e.titleKey)}
|
||||
icon={e.icon}
|
||||
onPress={() => 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}
|
||||
/>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
<SettingsSection>
|
||||
<View className='p-3'>
|
||||
<StorageSettings />
|
||||
</View>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
<QuickConnectSheet ref={quickConnectRef} />
|
||||
</ScrollView>
|
||||
|
||||
76
components/settings/index/SettingsHero.tsx
Normal file
76
components/settings/index/SettingsHero.tsx
Normal file
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
23
components/settings/index/SettingsSearchBar.tsx
Normal file
23
components/settings/index/SettingsSearchBar.tsx
Normal file
@@ -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 }) => (
|
||||
<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}
|
||||
clearButtonMode='while-editing'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
38
components/settings/index/settingsSearchIndex.ts
Normal file
38
components/settings/index/settingsSearchIndex.ts
Normal file
@@ -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"],
|
||||
},
|
||||
];
|
||||
50
components/settings/index/useSettingsSearch.ts
Normal file
50
components/settings/index/useSettingsSearch.ts
Normal file
@@ -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]);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user