Compare commits

...

25 Commits

Author SHA1 Message Date
Gauvain
da47ad0502 refactor(settings): restore expo-ui bottom sheet and direct clipboard (drop stale-build workarounds) 2026-06-04 00:36:44 +02:00
Gauvain
059c8bb854 feat(settings): populate deep-search index from sub-page audit 2026-06-04 00:23:22 +02:00
Gauvain
583fac6938 fix(settings): lazy-load clipboard so the Account screen opens without a native rebuild 2026-06-04 00:16:40 +02:00
Gauvain
f6333b49d3 fix(settings): use gorhom bottom sheet for QuickConnect (expo-ui needs a native rebuild) 2026-06-04 00:15:50 +02:00
Gauvain
f56b2bc4eb fix(settings): guard empty-token copy, search platform filter and clear button 2026-06-04 00:00:08 +02:00
Gauvain
ac5dcbe751 feat(settings): gate notifications page behind OS permission 2026-06-03 23:59:17 +02:00
Gauvain
752d1179bc feat(i18n): add settings-redo English keys (sections, account, notifications) 2026-06-03 23:51:47 +02:00
Gauvain
6a38c393e6 feat(settings): add profile hero and settings search 2026-06-03 23:48:22 +02:00
Gauvain
e476e0b4d9 feat(settings): add accent-insensitive search filter with tests 2026-06-03 23:41:40 +02:00
Gauvain
e783227ba6 feat(settings): add Account and Notifications pages, gate download notifications 2026-06-03 23:36:39 +02:00
Gauvain
a0e6b31500 fix(settings): correct QuickConnect sheet ref wiring and reset PIN on open 2026-06-03 23:32:44 +02:00
Gauvain
4e3f5a72e0 feat(settings): catalog-driven index + migrate QuickConnect to expo-ui sheet 2026-06-03 23:27:46 +02:00
Gauvain
25c0663d2f feat(settings): add data-driven settings catalog 2026-06-03 23:22:33 +02:00
Gauvain
81e97740f7 feat(settings): add SettingsSection component 2026-06-03 23:20:37 +02:00
Gauvain
1349ad7dc0 feat(settings): add SettingsRow component 2026-06-03 23:19:21 +02:00
Gauvain
4df9690d22 fix(list): unify ListItem row height and padding 2026-06-03 23:17:45 +02:00
Gauvain
3dfae1a1b4 feat(settings): add notifications, landing-tab and wifi-only setting keys 2026-06-03 23:16:14 +02:00
Gauvain
c7e3ceeefb chore(settings): add expo-clipboard for token copy 2026-06-03 23:14:41 +02:00
Fredrik Burmester
46d96d5965 fix(music): use white tab labels instead of default blue (#1657)
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-02 23:36:50 +02:00
Kylian Ronfleux--Corail
7d16e7d5c7 fix: Improve perf download database and correct crash when changing episode (#1547)
Co-authored-by: BuildTools <unconfigured@null.spigotmc.org>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-02 22:37:16 +02:00
Alex
ceb9b5a1ae fix(downloads): poster images not showing for downloaded content (#1652)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-02 19:47:14 +02:00
Gauvain
1144ff5049 fix(player): correct prev/next episode buttons at boundaries & for missing episodes (#1649) 2026-06-02 19:12:21 +02:00
Alex
4d508a4315 fix(platform-dropdown-component): Auto-size iOS trigger wrapper to pr… (#1650) 2026-06-02 18:40:47 +02:00
Alex
915a4febbb fix(player): respect safe area in chapter list and refine UX (#1653) 2026-06-02 22:31:49 +10:00
Alex
88163eb531 fix(mpv): release audio session on player exit so other apps' audio resumes (#1651)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
2026-06-02 19:20:56 +10:00
38 changed files with 1269 additions and 349 deletions

View File

@@ -243,6 +243,28 @@ export default function IndexLayout() {
headerLeft: () => <HeaderBackButton />, 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]) => ( {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} /> <Stack.Screen key={name} name={name} options={options} />
))} ))}

View File

@@ -1,38 +1,44 @@
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useEffect, useRef, useState } from "react";
import { useEffect } from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; 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 { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { QuickConnect } from "@/components/settings/QuickConnect"; import { SettingsHero } from "@/components/settings/index/SettingsHero";
import { SettingsRow } from "@/components/settings/index/SettingsRow";
import { SettingsSearchBar } from "@/components/settings/index/SettingsSearchBar";
import { SettingsSection } from "@/components/settings/index/SettingsSection";
import {
SETTINGS_CATALOG,
type SettingsTarget,
} from "@/components/settings/index/settingsCatalog";
import { useSettingsSearch } from "@/components/settings/index/useSettingsSearch";
import {
QuickConnectSheet,
type QuickConnectSheetRef,
} from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings"; import { StorageSettings } from "@/components/settings/StorageSettings";
import { UserInfo } from "@/components/settings/UserInfo";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { useJellyfin } from "@/providers/JellyfinProvider";
// TV-specific settings component
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null; const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
// Mobile settings component
function SettingsMobile() { function SettingsMobile() {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [_user] = useAtom(userAtom);
const { logout } = useJellyfin(); const { logout } = useJellyfin();
const navigation = useNavigation(); 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(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity <TouchableOpacity onPress={() => logout()}>
onPress={() => {
logout();
}}
>
<Text className='text-red-600 px-2'> <Text className='text-red-600 px-2'>
{t("home.settings.log_out_button")} {t("home.settings.log_out_button")}
</Text> </Text>
@@ -41,98 +47,95 @@ function SettingsMobile() {
}); });
}, []); }, []);
const handleTarget = (target: SettingsTarget) => {
if (target.type === "action") {
if (target.action === "quickConnect") {
quickConnectRef.current?.present();
}
return;
}
router.push(target.route as any);
};
return ( return (
<ScrollView <ScrollView
contentInsetAdjustmentBehavior='automatic' contentInsetAdjustmentBehavior='automatic'
keyboardShouldPersistTaps='handled'
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
paddingTop: 8,
paddingBottom: 32,
}} }}
> >
<View {!searching && (
className='p-4 flex flex-col' <SettingsHero
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} onPress={() => router.push("/settings/account/page" as any)}
> />
<View className='mb-4'> )}
<UserInfo /> <SettingsSearchBar value={query} onChange={setQuery} />
</View>
<QuickConnect className='mb-4' /> {searching ? (
<SettingsSection title={t("home.settings.search_results")}>
{Platform.OS !== "ios" && ( {results.length === 0 ? (
<View className='mb-4'> <View className='px-4 py-3'>
<ListGroup title={t("pairing.pair_with_phone_title")}> <Text className='text-[#9899A1]'>
<ListItem {t("home.settings.search_no_results")}
onPress={() => </Text>
router.push("/(auth)/(tabs)/(home)/companion-login") </View>
} ) : (
title={t("pairing.pair_with_phone")} results.map((r, i) => (
textColor='blue' <SettingsRow
key={r.id}
title={r.title}
icon={r.icon}
value={r.subtitle}
onPress={() => handleTarget(r.target)}
isLast={i === results.length - 1}
/> />
</ListGroup> ))
)}
</SettingsSection>
) : (
<>
<View className='mx-3 mb-5'>
<AppLanguageSelector />
</View> </View>
)} {SETTINGS_CATALOG.map((section) => {
const entries = section.entries.filter(
(e) => !e.platforms || e.platforms.includes(os),
);
if (entries.length === 0) return null;
return (
<SettingsSection key={section.id} title={t(section.titleKey)}>
{entries.map((e, i) => (
<SettingsRow
key={e.id}
title={t(e.titleKey)}
icon={e.icon}
onPress={() => handleTarget(e.target)}
isLast={i === entries.length - 1}
/>
))}
</SettingsSection>
);
})}
<SettingsSection>
<View className='p-3'>
<StorageSettings />
</View>
</SettingsSection>
</>
)}
<View className='mb-4'> <QuickConnectSheet ref={quickConnectRef} />
<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> </ScrollView>
); );
} }
export default function settings() { export default function settings() {
// Use TV settings component on TV platforms
if (Platform.isTV && SettingsTV) { if (Platform.isTV && SettingsTV) {
return <SettingsTV />; return <SettingsTV />;
} }
return <SettingsMobile />; return <SettingsMobile />;
} }

View File

@@ -0,0 +1,56 @@
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;
await setStringAsync(token);
success();
Alert.alert(t("home.settings.account.copied"));
};
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

@@ -0,0 +1,68 @@
import { Ionicons } from "@expo/vector-icons";
import * as Notifications from "expo-notifications";
import { t } from "i18next";
import { useEffect, useState } from "react";
import { Linking, ScrollView, Switch, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { useSettings } from "@/utils/atoms/settings";
export default function NotificationsPage() {
const { settings, updateSettings } = useSettings();
const [perm, setPerm] =
useState<Notifications.NotificationPermissionsStatus | null>(null);
useEffect(() => {
Notifications.getPermissionsAsync().then(setPerm);
}, []);
const requestPermission = async () => {
const res = await Notifications.requestPermissionsAsync();
setPerm(res);
// Only bounce to system settings when the OS will not prompt again.
if (!res.granted && res.canAskAgain === false) {
Linking.openSettings();
}
};
if (!settings || perm === null) return null;
if (!perm.granted) {
return (
<View className='flex-1 items-center justify-center px-8'>
<Ionicons name='notifications-off-outline' size={56} color='#5A5960' />
<Text className='text-white text-lg font-semibold mt-4 text-center'>
{t("home.settings.notifications.disabled_title")}
</Text>
<Text className='text-[#9899A1] text-center mt-2'>
{t("home.settings.notifications.disabled_description")}
</Text>
<Button color='purple' className='mt-6' onPress={requestPermission}>
{t("home.settings.notifications.enable_button")}
</Button>
</View>
);
}
return (
<ScrollView contentContainerStyle={{ padding: 16 }}>
<ListGroup title={t("home.settings.notifications.events_title")}>
<ListItem title={t("home.settings.notifications.master")}>
<Switch
value={settings.notificationsEnabled}
onValueChange={(v) => updateSettings({ notificationsEnabled: v })}
/>
</ListItem>
<ListItem title={t("home.settings.notifications.downloads")}>
<Switch
value={settings.notifyDownloads}
disabled={!settings.notificationsEnabled}
onValueChange={(v) => updateSettings({ notifyDownloads: v })}
/>
</ListItem>
</ListGroup>
</ScrollView>
);
}

View File

@@ -40,6 +40,8 @@ const Layout = () => {
keyboardDismissMode='none' keyboardDismissMode='none'
screenOptions={{ screenOptions={{
tabBarBounces: true, tabBarBounces: true,
tabBarActiveTintColor: "#FFFFFF",
tabBarInactiveTintColor: "#9CA3AF",
tabBarLabelStyle: { tabBarLabelStyle: {
fontSize: TAB_LABEL_FONT_SIZE, fontSize: TAB_LABEL_FONT_SIZE,
fontWeight: "600", fontWeight: "600",

View File

@@ -274,6 +274,11 @@ export default function DirectPlayerPage() {
}; };
if (itemId) { if (itemId) {
setItem(null);
setDownloadedItem(null);
// Clear the previous episode's stream so the loader gate stays closed
// until the new item's stream resolves (avoids a stale MPV source frame).
setStream(null);
fetchItemData(); fetchItemData();
} }
}, [itemId, offline, api, user?.Id]); }, [itemId, offline, api, user?.Id]);
@@ -316,6 +321,12 @@ export default function DirectPlayerPage() {
return null; return null;
} }
// Ensure item matches the current itemId to avoid race conditions
if (item.Id !== itemId) {
setStreamStatus({ isLoading: false, isError: false });
return null;
}
let result: Stream | null = null; let result: Stream | null = null;
if (offline && downloadedItem?.mediaSource) { if (offline && downloadedItem?.mediaSource) {
const url = downloadedItem.videoFilePath; const url = downloadedItem.videoFilePath;
@@ -388,6 +399,7 @@ export default function DirectPlayerPage() {
item, item,
user?.Id, user?.Id,
downloadedItem, downloadedItem,
offline,
]); ]);
useEffect(() => { useEffect(() => {

View File

@@ -31,6 +31,7 @@
"expo-brightness": "~56.0.5", "expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.15", "expo-build-properties": "~56.0.15",
"expo-camera": "~56.0.7", "expo-camera": "~56.0.7",
"expo-clipboard": "~56.0.3",
"expo-constants": "~56.0.16", "expo-constants": "~56.0.16",
"expo-crypto": "~56.0.4", "expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.16", "expo-dev-client": "~56.0.16",
@@ -955,6 +956,8 @@
"expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="], "expo-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-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=="], "expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],

View File

@@ -1,13 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect, useState } from "react"; import React, { useEffect } from "react";
import { import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
type LayoutChangeEvent,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -217,24 +211,6 @@ const PlatformDropdownComponent = ({
}: PlatformDropdownProps) => { }: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal(); const { showModal, hideModal, isVisible } = useGlobalModal();
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
// `matchContents` doesn't help here: it reports the native Menu's size via
// setStyleSize and overrides any explicit size. Instead we measure the
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
const [triggerSize, setTriggerSize] = useState<{
width: number;
height: number;
} | null>(null);
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setTriggerSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
};
// Handle controlled open state for Android // Handle controlled open state for Android
useEffect(() => { useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) { if (Platform.OS === "android" && controlledOpen === true) {
@@ -265,25 +241,11 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]); }, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios" && !Platform.isTV) { if (Platform.OS === "ios" && !Platform.isTV) {
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55) // @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
// fills its parent and reports its own size via setStyleSize, so it can't // the trigger sizes the wrapper while the Host overlays the real Menu.
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
// height depends on the parent while the parent depends on the Host — a
// circular dependency that collapses to 0 for any selector nested more than
// one level deep (so only the first, shallowest dropdown stays visible).
// Giving the wrapper the measured size breaks the cycle; the Host then
// fills a concrete box.
return ( return (
<View style={triggerSize ?? { opacity: 0 }}> <View>
{/* Hidden measurer: lays the trigger out off-flow to capture its <View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
sizes to the trigger's content rather than to its parent. */}
<View
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
pointerEvents='none'
aria-hidden
onLayout={handleMeasureTrigger}
>
{trigger} {trigger}
</View> </View>
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}> <Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>

View File

@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native"; import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { import {
type ChapterEntry, type ChapterEntry,
chapterStartsMs, chapterStartsMs,
@@ -38,6 +39,7 @@ function ChapterListComponent({
onClose, onClose,
}: ChapterListProps) { }: ChapterListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const safeArea = useControlsSafeAreaInsets();
const listRef = useRef<FlatList<ChapterEntry>>(null); const listRef = useRef<FlatList<ChapterEntry>>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]); const entries = useMemo(() => sortedChapters(chapters), [chapters]);
@@ -79,7 +81,17 @@ function ChapterListComponent({
supportedOrientations={["portrait", "landscape"]} supportedOrientations={["portrait", "landscape"]}
> >
<Pressable onPress={onClose} style={styles.backdrop}> <Pressable onPress={onClose} style={styles.backdrop}>
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}> <Pressable
onPress={(e) => e.stopPropagation()}
style={[
styles.sheet,
{
marginLeft: safeArea.left,
marginRight: safeArea.right,
paddingBottom: safeArea.bottom,
},
]}
>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>{t("chapters.title")}</Text> <Text style={styles.title}>{t("chapters.title")}</Text>
<Pressable <Pressable
@@ -160,14 +172,12 @@ const styles = StyleSheet.create({
backdrop: { backdrop: {
flex: 1, flex: 1,
justifyContent: "flex-end", justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.6)",
}, },
sheet: { sheet: {
backgroundColor: Colors.background, backgroundColor: Colors.background,
borderTopLeftRadius: 16, borderTopLeftRadius: 16,
borderTopRightRadius: 16, borderTopRightRadius: 16,
maxHeight: "70%", maxHeight: "70%",
paddingBottom: 24,
}, },
header: { header: {
flexDirection: "row", flexDirection: "row",

View File

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

View File

@@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const isOffline = useOfflineMode();
const router = useRouter(); const router = useRouter();
const isOffline = useOfflineMode();
// Read the live (cached) downloads DB inside the query rather than the
// provider's downloadedItems snapshot, so refetches after
// updateDownloadedItem() reflect the latest state instead of a stale
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
const { getDownloadedItems } = useDownload(); const { getDownloadedItems } = useDownload();
const scrollRef = useRef<HorizontalScrollRef>(null); const scrollRef = useRef<HorizontalScrollRef>(null);
@@ -100,7 +104,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
onPress={() => { onPress={() => {
router.setParams({ id: _item.Id }); router.setParams({ id: _item.Id });
}} }}
className={`flex flex-col w-44 className={`flex flex-col w-44
${item?.Id === _item.Id ? "" : "opacity-50"} ${item?.Id === _item.Id ? "" : "opacity-50"}
`} `}
> >

View File

@@ -1,54 +1,57 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import {
import { useCallback, useMemo, useRef, useState } from "react"; forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, View, type ViewProps } from "react-native"; import { Alert, Platform, View } from "react-native";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { Button } from "../Button"; import { Button } from "../Button";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { PinInput } from "../inputs/PinInput"; import { PinInput } from "../inputs/PinInput";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {} export type QuickConnectSheetRef = { present: () => void };
export const QuickConnect: React.FC<Props> = ({ ...props }) => { export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
const isTv = Platform.isTV; (_props, ref) => {
const [api] = useAtom(apiAtom); const isTv = Platform.isTV;
const [user] = useAtom(userAtom); const [api] = useAtom(apiAtom);
const [quickConnectCode, setQuickConnectCode] = useState<string>(); const [user] = useAtom(userAtom);
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const [quickConnectCode, setQuickConnectCode] = useState<string>();
const successHapticFeedback = useHaptic("success"); const modalRef = useRef<BottomSheetMethods>(null);
const errorHapticFeedback = useHaptic("error"); const successHapticFeedback = useHaptic("success");
const snapPoints = useMemo( const errorHapticFeedback = useHaptic("error");
() => (Platform.OS === "android" ? ["100%"] : ["40%"]), const snapPoints = useMemo(
[], () => (Platform.OS === "android" ? ["100%"] : ["40%"]),
); [],
const isAndroid = Platform.OS === "android"; );
const isAndroid = Platform.OS === "android";
const { t } = useTranslation();
const { t } = useTranslation(); useImperativeHandle(
ref,
() => ({
present: () => {
setQuickConnectCode("");
modalRef.current?.present();
},
}),
[],
);
const renderBackdrop = useCallback( const authorizeQuickConnect = useCallback(async () => {
(props: BottomSheetBackdropProps) => ( if (!quickConnectCode) return;
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const authorizeQuickConnect = useCallback(async () => {
if (quickConnectCode) {
try { try {
const res = await getQuickConnectApi(api!).authorizeQuickConnect({ const res = await getQuickConnectApi(api!).authorizeQuickConnect({
code: quickConnectCode, code: quickConnectCode,
@@ -61,7 +64,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
t("home.settings.quick_connect.quick_connect_autorized"), t("home.settings.quick_connect.quick_connect_autorized"),
); );
setQuickConnectCode(undefined); setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close(); modalRef.current?.close();
} else { } else {
errorHapticFeedback(); errorHapticFeedback();
Alert.alert( Alert.alert(
@@ -76,39 +79,26 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
t("home.settings.quick_connect.invalid_code"), t("home.settings.quick_connect.invalid_code"),
); );
} }
} }, [
}, [api, user, quickConnectCode]); api,
user,
quickConnectCode,
t,
successHapticFeedback,
errorHapticFeedback,
]);
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 <BottomSheetModal
ref={bottomSheetModalRef} ref={modalRef}
enablePanDownToClose
snapPoints={snapPoints} snapPoints={snapPoints}
handleIndicatorStyle={{ handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundColor: "white", backgroundStyle={{ backgroundColor: "#171717" }}
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
keyboardBehavior={isAndroid ? "fillParent" : "interactive"} keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore' keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
topInset={isAndroid ? 0 : undefined}
> >
<BottomSheetView> <BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> <View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
@@ -142,6 +132,8 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
</View> </View>
</BottomSheetView> </BottomSheetView>
</BottomSheetModal> </BottomSheetModal>
</View> );
); },
}; );
QuickConnectSheet.displayName = "QuickConnectSheet";

View 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>
);
};

View File

@@ -0,0 +1,62 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useHaptic } from "@/hooks/useHaptic";
export interface SettingsRowProps {
title: string;
icon: keyof typeof Ionicons.glyphMap;
value?: string;
showChevron?: boolean;
onPress: () => void;
isLast?: boolean;
}
const ACCENT = "#a855f7"; // single accent (full theming is a separate sub-project)
export const SettingsRow: React.FC<SettingsRowProps> = ({
title,
icon,
value,
showChevron = true,
onPress,
isLast = false,
}) => {
const haptic = useHaptic("light"); // no-op when disableHapticFeedback is set
return (
<TouchableOpacity
onPress={() => {
haptic();
onPress();
}}
className={`flex flex-row items-center bg-neutral-900 h-[48px] px-3 ${
isLast ? "" : "border-b border-[#ffffff14]"
}`}
>
<View
className='h-[29px] w-[29px] rounded-[7px] items-center justify-center mr-3'
style={{ backgroundColor: `${ACCENT}29` }}
>
<Ionicons name={icon} size={17} color='#c79bff' />
</View>
<Text className='flex-1 text-white text-[15px]' numberOfLines={1}>
{title}
</Text>
{value ? (
<Text className='text-[#9899A1] text-[15px] ml-2' numberOfLines={1}>
{value}
</Text>
) : null}
{showChevron ? (
<Ionicons
name='chevron-forward'
size={17}
color='#5A5960'
style={{ marginLeft: 4 }}
/>
) : null}
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,28 @@
import { Ionicons } from "@expo/vector-icons";
import { t } from "i18next";
import type React from "react";
import { TextInput, TouchableOpacity, View } from "react-native";
export const SettingsSearchBar: React.FC<{
value: string;
onChange: (v: string) => void;
}> = ({ value, onChange }) => (
<View className='mx-3 mb-4 h-[38px] rounded-xl bg-neutral-800 flex-row items-center px-3'>
<Ionicons name='search' size={16} color='#76767c' />
<TextInput
value={value}
onChangeText={onChange}
placeholder={t("home.settings.search_placeholder")}
placeholderTextColor='#76767c'
className='flex-1 ml-2 text-white text-[15px]'
autoCapitalize='none'
autoCorrect={false}
returnKeyType='search'
/>
{value.length > 0 ? (
<TouchableOpacity onPress={() => onChange("")} hitSlop={8}>
<Ionicons name='close-circle' size={18} color='#76767c' />
</TouchableOpacity>
) : null}
</View>
);

View File

@@ -0,0 +1,19 @@
import type React from "react";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
export const SettingsSection: React.FC<{
title?: string;
children: React.ReactNode;
}> = ({ title, children }) => (
<View className='mb-5'>
{title ? (
<Text className='ml-4 mb-1.5 uppercase text-[#8E8D91] text-[11px] tracking-wide'>
{title}
</Text>
) : null}
<View className='mx-3 rounded-xl overflow-hidden bg-neutral-900'>
{children}
</View>
</View>
);

View File

@@ -0,0 +1,22 @@
import { expect, test } from "bun:test";
import { matchesQuery, normalize } from "./searchFilter";
test("normalize strips accents and lowercases", () => {
expect(normalize("Légèreté")).toBe("legerete");
expect(normalize(" AUDIO ")).toBe("audio");
});
test("matchesQuery matches title case/accent-insensitively", () => {
expect(matchesQuery({ title: "Apparence", keywords: [] }, "appar")).toBe(
true,
);
expect(
matchesQuery({ title: "Audio", keywords: ["sous-titres"] }, "SOUS"),
).toBe(true);
expect(matchesQuery({ title: "Music", keywords: [] }, "xyz")).toBe(false);
});
test("matchesQuery returns true for empty query", () => {
expect(matchesQuery({ title: "Anything" }, "")).toBe(true);
expect(matchesQuery({ title: "Anything" }, " ")).toBe(true);
});

View File

@@ -0,0 +1,14 @@
export const normalize = (s: string): string =>
s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
export interface Searchable {
title: string;
keywords?: string[];
}
export const matchesQuery = (item: Searchable, query: string): boolean => {
const q = normalize(query);
if (!q) return true;
const hay = normalize([item.title, ...(item.keywords ?? [])].join(" "));
return hay.includes(q);
};

View File

@@ -0,0 +1,125 @@
import type { Ionicons } from "@expo/vector-icons";
export type SettingsTarget =
| { type: "route"; route: string }
| { type: "action"; action: "quickConnect" };
export interface SettingsEntry {
id: string;
titleKey: string;
icon: keyof typeof Ionicons.glyphMap;
target: SettingsTarget;
/** extra search terms (English); the title is always searched too */
keywords?: string[];
/** when set, entry only shows on these platforms */
platforms?: ("ios" | "android")[];
}
export interface SettingsSectionDef {
id: string;
titleKey: string;
entries: SettingsEntry[];
}
/**
* Single source of truth for the Settings index: drives both rendering and search.
*
* EXTENSIBLE SHELL — to add a setting that lands from an in-flight PR, append one
* entry to the right section (and, if it lives inside a sub-page, an entry in
* settingsSearchIndex.ts). No screen rewrite needed. Reserved slots (add when the
* PR merges): sleep timer #922, sync-play #1612, wake-on-LAN #1539 (advanced);
* download location #1486/#1193, clear image cache #1589 (storage/downloads);
* double-tap seek #1219/#1289, subtitle background #1543 (playback).
*/
export const SETTINGS_CATALOG: SettingsSectionDef[] = [
{
id: "playback",
titleKey: "home.settings.categories.playback",
entries: [
{
id: "playback-controls",
titleKey: "home.settings.playback_controls.title",
icon: "play",
target: { type: "route", route: "/settings/playback-controls/page" },
keywords: ["speed", "skip", "autoplay", "orientation"],
},
{
id: "audio-subtitles",
titleKey: "home.settings.audio_subtitles.title",
icon: "chatbox-ellipses",
target: { type: "route", route: "/settings/audio-subtitles/page" },
keywords: ["subtitle", "audio", "language"],
},
{
id: "music",
titleKey: "home.settings.music.title",
icon: "musical-notes",
target: { type: "route", route: "/settings/music/page" },
},
],
},
{
id: "personalization",
titleKey: "home.settings.categories.personalization",
entries: [
{
id: "appearance",
titleKey: "home.settings.appearance.title",
icon: "color-palette",
target: { type: "route", route: "/settings/appearance/page" },
},
{
id: "notifications",
titleKey: "home.settings.notifications.title",
icon: "notifications",
target: { type: "route", route: "/settings/notifications/page" },
},
],
},
{
id: "advanced",
titleKey: "home.settings.categories.advanced",
entries: [
{
id: "quick-connect",
titleKey: "home.settings.quick_connect.quick_connect_title",
icon: "key",
target: { type: "action", action: "quickConnect" },
},
{
id: "pair",
titleKey: "pairing.pair_with_phone",
icon: "phone-portrait",
target: {
type: "route",
route: "/(auth)/(tabs)/(home)/companion-login",
},
platforms: ["android"],
},
{
id: "plugins",
titleKey: "home.settings.plugins.plugins_title",
icon: "extension-puzzle",
target: { type: "route", route: "/settings/plugins/page" },
},
{
id: "network",
titleKey: "home.settings.network.title",
icon: "wifi",
target: { type: "route", route: "/settings/network/page" },
},
{
id: "logs",
titleKey: "home.settings.logs.logs_title",
icon: "document-text",
target: { type: "route", route: "/settings/logs/page" },
},
{
id: "intro",
titleKey: "home.settings.intro.title",
icon: "information-circle",
target: { type: "route", route: "/settings/intro/page" },
},
],
},
];

View File

@@ -0,0 +1,266 @@
export interface SearchableOption {
titleKey: string;
parentRoute: string;
parentTitleKey: string;
keywords?: string[];
platforms?: ("ios" | "android")[];
}
/**
* Internal options of sub-pages, for deep search ("index + internal settings").
* Populated from a per-sub-page audit of every user-facing ListItem/dropdown row.
* Every titleKey/parentTitleKey MUST be a real existing i18n key.
*/
export const SETTINGS_SEARCH_INDEX: SearchableOption[] = [
// --- Playback & Controls -------------------------------------------------
// MediaToggles
{
titleKey: "home.settings.media_controls.forward_skip_length",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["skip", "forward", "seconds"],
},
{
titleKey: "home.settings.media_controls.rewind_length",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["rewind", "back", "seconds"],
},
// GestureControls
{
titleKey: "home.settings.gesture_controls.horizontal_swipe_skip",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["gesture", "swipe", "skip", "seek"],
},
{
titleKey: "home.settings.gesture_controls.left_side_brightness",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["gesture", "swipe", "brightness", "left"],
},
{
titleKey: "home.settings.gesture_controls.right_side_volume",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["gesture", "swipe", "volume", "right"],
},
{
titleKey: "home.settings.gesture_controls.hide_volume_slider",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["volume", "slider", "hide", "gesture"],
},
{
titleKey: "home.settings.gesture_controls.hide_brightness_slider",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["brightness", "slider", "hide", "gesture"],
},
// PlaybackControlsSettings (home.settings.other.*)
{
titleKey: "home.settings.other.video_orientation",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["orientation", "rotate", "landscape", "portrait"],
},
{
titleKey: "home.settings.other.safe_area_in_controls",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["safe", "area", "notch", "controls", "inset"],
},
{
titleKey: "home.settings.other.default_quality",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["quality", "bitrate", "resolution"],
},
{
titleKey: "home.settings.other.default_playback_speed",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["playback", "speed", "rate"],
},
{
titleKey: "home.settings.other.disable_haptic_feedback",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["haptic", "vibration", "feedback"],
},
{
titleKey: "home.settings.other.auto_play_next_episode",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["autoplay", "auto", "next", "episode"],
},
{
titleKey: "home.settings.other.max_auto_play_episode_count",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["autoplay", "auto", "episode", "count", "limit"],
},
// MpvBufferSettings
{
titleKey: "home.settings.buffer.cache_mode",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["cache", "buffer", "mode"],
},
{
titleKey: "home.settings.buffer.buffer_duration",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["buffer", "duration", "cache", "seconds"],
},
{
titleKey: "home.settings.buffer.max_cache_size",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["buffer", "cache", "size", "memory"],
},
// MpvVoSettings (Android only)
{
titleKey: "home.settings.vo_driver.vo_mode",
parentRoute: "/settings/playback-controls/page",
parentTitleKey: "home.settings.playback_controls.title",
keywords: ["video", "output", "driver", "gpu", "mpv"],
platforms: ["android"],
},
// --- Audio & Subtitles ---------------------------------------------------
// AudioToggles
{
titleKey: "home.settings.audio.set_audio_track",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["audio", "track", "remember", "previous"],
},
{
titleKey: "home.settings.audio.audio_language",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["audio", "language", "default"],
},
{
titleKey: "home.settings.audio.transcode_mode.title",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["audio", "transcode", "surround", "stereo", "passthrough"],
},
// SubtitleToggles
{
titleKey: "home.settings.subtitles.subtitle_language",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["subtitle", "caption", "language", "default"],
},
{
titleKey: "home.settings.subtitles.subtitle_mode",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["subtitle", "caption", "mode", "forced"],
},
{
titleKey: "home.settings.subtitles.set_subtitle_track",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["subtitle", "caption", "track", "remember", "previous"],
},
{
titleKey: "home.settings.subtitles.subtitle_size",
parentRoute: "/settings/audio-subtitles/page",
parentTitleKey: "home.settings.audio_subtitles.title",
keywords: ["subtitle", "size", "caption", "scale", "font"],
},
// --- Music ---------------------------------------------------------------
{
titleKey: "home.settings.music.prefer_downloaded",
parentRoute: "/settings/music/page",
parentTitleKey: "home.settings.music.title",
keywords: ["music", "downloaded", "offline", "local"],
},
{
titleKey: "home.settings.music.lookahead_enabled",
parentRoute: "/settings/music/page",
parentTitleKey: "home.settings.music.title",
keywords: ["music", "lookahead", "cache", "prefetch"],
},
{
titleKey: "home.settings.music.lookahead_count",
parentRoute: "/settings/music/page",
parentTitleKey: "home.settings.music.title",
keywords: ["music", "lookahead", "cache", "count", "tracks"],
},
{
titleKey: "home.settings.music.max_cache_size",
parentRoute: "/settings/music/page",
parentTitleKey: "home.settings.music.title",
keywords: ["music", "cache", "size", "storage"],
},
{
titleKey: "home.settings.storage.clear_music_cache",
parentRoute: "/settings/music/page",
parentTitleKey: "home.settings.music.title",
keywords: ["music", "cache", "clear", "storage"],
},
{
titleKey: "home.settings.storage.delete_all_downloaded_songs",
parentRoute: "/settings/music/page",
parentTitleKey: "home.settings.music.title",
keywords: ["music", "downloaded", "delete", "songs", "storage"],
},
// --- Appearance ----------------------------------------------------------
{
titleKey: "home.settings.other.show_custom_menu_links",
parentRoute: "/settings/appearance/page",
parentTitleKey: "home.settings.appearance.title",
keywords: ["menu", "links", "custom", "navigation"],
},
{
titleKey: "home.settings.appearance.merge_next_up_continue_watching",
parentRoute: "/settings/appearance/page",
parentTitleKey: "home.settings.appearance.title",
keywords: ["continue", "watching", "next", "up", "merge", "home"],
},
{
titleKey: "home.settings.other.hide_libraries",
parentRoute: "/settings/appearance/page",
parentTitleKey: "home.settings.appearance.title",
keywords: ["hide", "libraries", "library", "home"],
},
{
titleKey: "home.settings.appearance.hide_remote_session_button",
parentRoute: "/settings/appearance/page",
parentTitleKey: "home.settings.appearance.title",
keywords: ["remote", "session", "button", "hide", "cast"],
},
// --- Network -------------------------------------------------------------
{
titleKey: "home.settings.network.remote_url",
parentRoute: "/settings/network/page",
parentTitleKey: "home.settings.network.title",
keywords: ["remote", "url", "server", "address"],
},
{
titleKey: "home.settings.network.active_url",
parentRoute: "/settings/network/page",
parentTitleKey: "home.settings.network.title",
keywords: ["active", "url", "server", "connection"],
},
{
titleKey: "home.settings.network.auto_switch_enabled",
parentRoute: "/settings/network/page",
parentTitleKey: "home.settings.network.title",
keywords: ["auto", "switch", "local", "wifi", "network"],
},
{
titleKey: "home.settings.network.local_url",
parentRoute: "/settings/network/page",
parentTitleKey: "home.settings.network.title",
keywords: ["local", "url", "lan", "server", "address"],
},
];

View File

@@ -0,0 +1,51 @@
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 (o.platforms && !o.platforms.includes(os)) continue;
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]);
};

View File

@@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import { type SharedValue } from "react-native-reanimated"; import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChapterList } from "@/components/chapters/ChapterList"; import { ChapterList } from "@/components/chapters/ChapterList";
import { ChapterTicks } from "@/components/chapters/ChapterTicks"; import { ChapterTicks } from "@/components/chapters/ChapterTicks";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters"; import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
@@ -75,9 +75,6 @@ interface BottomControlsProps {
minutes: number; minutes: number;
seconds: number; seconds: number;
}; };
// Chapter props
chapterPositions?: number[];
} }
export const BottomControls: FC<BottomControlsProps> = ({ export const BottomControls: FC<BottomControlsProps> = ({
@@ -111,11 +108,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl, trickPlayUrl,
trickplayInfo, trickplayInfo,
time, time,
chapterPositions = [],
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useControlsSafeAreaInsets();
const [chapterListVisible, setChapterListVisible] = useState(false); const [chapterListVisible, setChapterListVisible] = useState(false);
// Only expose chapter UI when there are at least two real markers. // Only expose chapter UI when there are at least two real markers.
@@ -146,13 +142,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
style={[ style={[
{ {
position: "absolute", position: "absolute",
right: right: insets.right,
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, left: insets.left,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, bottom: Math.max(insets.bottom - 17, 0),
bottom:
(settings?.safeAreaInControlsEnabled ?? true)
? Math.max(insets.bottom - 17, 0)
: 0,
}, },
]} ]}
className={"flex flex-col px-2"} className={"flex flex-col px-2"}
@@ -188,17 +180,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
) : null} ) : null}
</View> </View>
<View className='flex flex-row items-center space-x-2 shrink-0'> <View className='flex flex-row items-center space-x-2 shrink-0'>
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center mr-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
<SkipButton <SkipButton
showButton={showSkipButton} showButton={showSkipButton}
onPress={skipIntro} onPress={skipIntro}
@@ -230,6 +211,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
onPress={handleNextEpisodeManual} onPress={handleNextEpisodeManual}
/> />
)} )}
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center ml-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
</View> </View>
</View> </View>
<View <View

View File

@@ -1,9 +1,9 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { FC } from "react"; import type { FC } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "./AudioSlider"; import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider"; import BrightnessSlider from "./BrightnessSlider";
@@ -42,15 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
goToNextChapter, goToNextChapter,
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useControlsSafeAreaInsets();
return ( return (
<View <View
style={{ style={{
position: "absolute", position: "absolute",
top: "50%", top: "50%",
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, left: insets.left,
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, right: insets.right,
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",

View File

@@ -219,7 +219,6 @@ export const Controls: FC<Props> = ({
hasNextChapter, hasNextChapter,
goToPreviousChapter, goToPreviousChapter,
goToNextChapter, goToNextChapter,
chapterPositions,
} = useChapterNavigation({ } = useChapterNavigation({
chapters: item.Chapters, chapters: item.Chapters,
progress, progress,
@@ -366,7 +365,9 @@ export const Controls: FC<Props> = ({
{ applyLanguagePreferences: true }, { applyLanguagePreferences: true },
); );
const queryParams = new URLSearchParams({ // Use setParams instead of replace to avoid unmounting/remounting the player,
// which would create a new MPV native view and crash with "mp_initialize already initialized".
router.setParams({
...(offline && { offline: "true" }), ...(offline && { offline: "true" }),
itemId: item.Id ?? "", itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "", audioIndex: defaultAudioIndex?.toString() ?? "",
@@ -375,11 +376,17 @@ export const Controls: FC<Props> = ({
bitrateValue: bitrateValue?.toString(), bitrateValue: bitrateValue?.toString(),
playbackPosition: playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "", item.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString(); });
router.replace(`player/direct-player?${queryParams}` as any);
}, },
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router], [
settings,
subtitleIndex,
audioIndex,
mediaSource,
bitrateValue,
router,
offline,
],
); );
const goToPreviousItem = useCallback(() => { const goToPreviousItem = useCallback(() => {
@@ -585,7 +592,6 @@ export const Controls: FC<Props> = ({
trickPlayUrl={trickPlayUrl} trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo} trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime} time={isSliding || showRemoteBubble ? time : remoteTime}
chapterPositions={chapterPositions}
/> />
</Animated.View> </Animated.View>
</> </>

View File

@@ -5,7 +5,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { import {
HorizontalScroll, HorizontalScroll,
@@ -17,10 +16,10 @@ import {
SeasonDropdown, SeasonDropdown,
type SeasonIndexState, type SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { import {
getDownloadedEpisodesForSeason, getDownloadedEpisodesForSeason,
getDownloadedSeasonNumbers, getDownloadedSeasonNumbers,
@@ -46,8 +45,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
scrollViewRef.current?.scrollToIndex(index, 100); scrollViewRef.current?.scrollToIndex(index, 100);
}; };
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const { settings } = useSettings(); const insets = useControlsSafeAreaInsets();
const insets = useSafeAreaInsets();
// Set the initial season index // Set the initial season index
useEffect(() => { useEffect(() => {
@@ -59,6 +57,11 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
} }
}, []); }, []);
// Read the live (cached) downloads DB inside the query rather than the
// provider's downloadedItems snapshot. The snapshot only refreshes on the
// provider refreshKey, so after updateDownloadedItem() invalidates
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
const { getDownloadedItems } = useDownload(); const { getDownloadedItems } = useDownload();
const seasonIndex = seasonIndexState[item.ParentId ?? ""]; const seasonIndex = seasonIndexState[item.ParentId ?? ""];
@@ -182,12 +185,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
backgroundColor: "black", backgroundColor: "black",
height: "100%", height: "100%",
width: "100%", width: "100%",
paddingTop: paddingTop: insets.top,
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0, paddingLeft: insets.left,
paddingLeft: paddingRight: insets.right,
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
paddingRight:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
}} }}
> >
<View <View

View File

@@ -5,12 +5,11 @@ import type {
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { type FC, useCallback, useState } from "react"; import { type FC, useCallback, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { OrientationLock } from "@/packages/expo-screen-orientation"; import { OrientationLock } from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT, ICON_SIZES } from "./constants"; import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView"; import DropdownView from "./dropdown/DropdownView";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
@@ -58,9 +57,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
showTechnicalInfo = false, showTechnicalInfo = false,
onToggleTechnicalInfo, onToggleTechnicalInfo,
}) => { }) => {
const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useControlsSafeAreaInsets();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const { orientation, lockOrientation } = useOrientation(); const { orientation, lockOrientation } = useOrientation();
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false); const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
@@ -99,10 +97,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
style={[ style={[
{ {
position: "absolute", position: "absolute",
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0, top: insets.top,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, left: insets.left,
right: right: insets.right,
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
padding: HEADER_LAYOUT.CONTAINER_PADDING, padding: HEADER_LAYOUT.CONTAINER_PADDING,
}, },
]} ]}

View File

@@ -16,8 +16,8 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import type { TechnicalInfo } from "@/modules/mpv-player"; import type { TechnicalInfo } from "@/modules/mpv-player";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT } from "./constants"; import { HEADER_LAYOUT } from "./constants";
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode"; type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
currentAudioIndex, currentAudioIndex,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const { settings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const safeInsets = useControlsSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null); const [info, setInfo] = useState<TechnicalInfo | null>(null);
const opacity = useSharedValue(0); const opacity = useSharedValue(0);
@@ -268,14 +268,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
left: Math.max(insets.left, 48) + 20, left: Math.max(insets.left, 48) + 20,
} }
: { : {
top: top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
(settings?.safeAreaInControlsEnabled ?? true) left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
left:
(settings?.safeAreaInControlsEnabled ?? true)
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
}; };
const textStyle = Platform.isTV const textStyle = Platform.isTV

View File

@@ -0,0 +1,18 @@
import {
type EdgeInsets,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import { useSettings } from "@/utils/atoms/settings";
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
/**
* Returns safe-area insets to apply to in-player controls, honoring the
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
* returns zero insets so controls can sit flush against the screen edges.
*/
export const useControlsSafeAreaInsets = (): EdgeInsets => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
};

View File

@@ -1,3 +1,4 @@
import { File, Paths } from "expo-file-system";
import { useCallback } from "react"; import { useCallback } from "react";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
@@ -12,36 +13,28 @@ const useImageStorage = () => {
} }
}, []); }, []);
/**
* expo-file-system instead of fetch+Blob+FileReader: the latter silently
* resolves to an empty payload under RN's New Architecture.
*/
const image2Base64 = useCallback(async (url?: string | null) => { const image2Base64 = useCallback(async (url?: string | null) => {
if (!url) return null; if (!url) return null;
let blob: Blob; const tmpFile = new File(
Paths.cache,
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
);
try { try {
// Fetch the data from the URL const downloaded = await File.downloadFileAsync(url, tmpFile, {
const response = await fetch(url); idempotent: true,
blob = await response.blob(); });
return await downloaded.base64();
} catch (error) { } catch (error) {
console.warn("Error fetching image:", error); console.warn("Error fetching image:", error);
return null; return null;
} finally {
if (tmpFile.exists) tmpFile.delete();
} }
// Create a FileReader instance
const reader = new FileReader();
// Convert blob to base64
return new Promise<string>((resolve, reject) => {
reader.onloadend = () => {
if (typeof reader.result === "string") {
// Extract the base64 string (remove the data URL prefix)
const base64 = reader.result.split(",")[1];
resolve(base64);
} else {
reject(new Error("Failed to convert image to base64"));
}
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}, []); }, []);
const saveImage = useCallback( const saveImage = useCallback(

View File

@@ -109,30 +109,35 @@ export const usePlaybackManager = ({
staleTime: 0, staleTime: 0,
}); });
/**
* Derive prev/next from the current item's real position in the adjacent
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
* not guarantee a fixed [prev, current, next] shape — at the first/last
* episode it can still return the current item as the first/last entry — so
* length-based indexing wrongly surfaces the current episode as "previous".
*/
const currentIndex = useMemo(
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
[adjacentItems, item],
);
/** A neighbour is only navigable if it has an actual media file (not a
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
const previousItem = useMemo(() => { const previousItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) { if (!adjacentItems || currentIndex <= 0) return null;
return null; const candidate = adjacentItems[currentIndex - 1];
} return isNavigable(candidate) ? candidate : null;
}, [adjacentItems, currentIndex, item]);
if (adjacentItems.length === 2) {
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
}
return adjacentItems[0];
}, [adjacentItems, item]);
/** The next item in the series */ /** The next item in the series */
const nextItem = useMemo(() => { const nextItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) { if (!adjacentItems || currentIndex < 0) return null;
return null; const candidate = adjacentItems[currentIndex + 1];
} return isNavigable(candidate) ? candidate : null;
}, [adjacentItems, currentIndex, item]);
if (adjacentItems.length === 2) {
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
}
return adjacentItems[2];
}, [adjacentItems, item]);
/** /**
* Reports playback progress. * Reports playback progress.

View File

@@ -81,7 +81,6 @@ class MpvPlayerView: ExpoView {
private func setupView() { private func setupView() {
clipsToBounds = true clipsToBounds = true
backgroundColor = .black backgroundColor = .black
configureAudioSession()
videoContainer = UIView() videoContainer = UIView()
videoContainer.translatesAutoresizingMaskIntoConstraints = false videoContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -141,21 +140,26 @@ class MpvPlayerView: ExpoView {
CATransaction.commit() CATransaction.commit()
} }
// MARK: - Audio Session & Notifications
private func configureAudioSession() { private func configureAudioSession() {
let audioSession = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
do { do {
try audioSession.setCategory( try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
.playback, try session.setActive(true)
mode: .moviePlayback,
policy: .longFormAudio,
options: []
)
try audioSession.setActive(true)
} catch { } catch {
print("Failed to configure audio session: \(error)") print("Failed to configure audio session: \(error)")
} }
} }
// MARK: - Audio Session & Notifications
/// Deactivate the session AND reset the category `setActive(false)` alone
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
/// reactivation (foreground, route change, other modules) re-steals audio.
private func tearDownAudioSession() {
let session = AVAudioSession.sharedInstance()
try? session.setActive(false, options: .notifyOthersOnDeactivation)
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
}
private func setupNotifications() { private func setupNotifications() {
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio) // Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
@@ -270,6 +274,7 @@ class MpvPlayerView: ExpoView {
func play() { func play() {
intendedPlayState = true intendedPlayState = true
configureAudioSession()
setupRemoteCommands() setupRemoteCommands()
renderer?.play() renderer?.play()
pipController?.setPlaybackRate(1.0) pipController?.setPlaybackRate(1.0)
@@ -440,6 +445,7 @@ class MpvPlayerView: ExpoView {
renderer?.stop() renderer?.stop()
displayLayer.removeFromSuperlayer() displayLayer.removeFromSuperlayer()
clearNowPlayingInfo() clearNowPlayingInfo()
tearDownAudioSession()
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
} }
@@ -519,9 +525,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
} }
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) { func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
// Audio output is now active - this is the right time to activate audio session and set Now Playing print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
nowPlayingManager.activateAudioSession()
syncNowPlaying(isPlaying: !isPaused()) syncNowPlaying(isPlaying: !isPaused())
} }
} }

View File

@@ -52,6 +52,7 @@
"expo-brightness": "~56.0.5", "expo-brightness": "~56.0.5",
"expo-build-properties": "~56.0.15", "expo-build-properties": "~56.0.15",
"expo-camera": "~56.0.7", "expo-camera": "~56.0.7",
"expo-clipboard": "~56.0.3",
"expo-constants": "~56.0.16", "expo-constants": "~56.0.16",
"expo-crypto": "~56.0.4", "expo-crypto": "~56.0.4",
"expo-dev-client": "~56.0.16", "expo-dev-client": "~56.0.16",

View File

@@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json"; const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
let cachedDb: DownloadsDatabase | null = null;
let cacheVersion = 0;
// Performance optimization: Cache the flattened items array
let cachedItems: DownloadedItem[] | null = null;
let itemsCacheVersion = -1;
// Performance optimization: Index for O(1) item lookups by ID
let itemIndex: Map<string, DownloadedItem> | null = null;
let indexCacheVersion = -1;
/** /**
* Get the downloads database from storage * Get the downloads database from storage
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
* saveDownloadsDatabase() runs and the derived caches stay consistent.
*/ */
export function getDownloadsDatabase(): DownloadsDatabase { export function getDownloadsDatabase(): DownloadsDatabase {
// Return cached database if available
if (cachedDb !== null) {
return cachedDb;
}
// Parse from storage and cache the result
const file = storage.getString(DOWNLOADS_DATABASE_KEY); const file = storage.getString(DOWNLOADS_DATABASE_KEY);
if (file) { if (file) {
return JSON.parse(file) as DownloadsDatabase; cachedDb = JSON.parse(file) as DownloadsDatabase;
return cachedDb;
} }
return { movies: {}, series: {}, other: {} };
const emptyDb = { movies: {}, series: {}, other: {} };
cachedDb = emptyDb;
return emptyDb;
} }
/** /**
* Save the downloads database to storage * Save the downloads database to storage
* PERFORMANCE: Updates cache and invalidates derived caches
*/ */
export function saveDownloadsDatabase(db: DownloadsDatabase): void { export function saveDownloadsDatabase(db: DownloadsDatabase): void {
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db)); storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
// Update the cache with the new database
cachedDb = db;
// Invalidate derived caches (items array and index)
cachedItems = null;
itemIndex = null;
cacheVersion++;
} }
/** /**
* Get all downloaded items as a flat array * Get all downloaded items as a flat array
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
*/ */
export function getAllDownloadedItems(): DownloadedItem[] { export function getAllDownloadedItems(): DownloadedItem[] {
// Return cached items if available and up-to-date
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
return cachedItems;
}
// Build the items array from the database
const db = getDownloadsDatabase(); const db = getDownloadsDatabase();
const items: DownloadedItem[] = []; const items: DownloadedItem[] = [];
@@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] {
} }
} }
// Cache the result
cachedItems = items;
itemsCacheVersion = cacheVersion;
return items; return items;
} }
/** /**
* Get a downloaded item by its ID * Build or refresh the item index for O(1) lookups
*/ */
export function getDownloadedItemById(id: string): DownloadedItem | undefined { function ensureItemIndex(): void {
const db = getDownloadsDatabase(); if (itemIndex !== null && indexCacheVersion === cacheVersion) {
return; // Index is up-to-date
if (db.movies[id]) {
return db.movies[id];
} }
for (const series of Object.values(db.series)) { // Build new index from all items
for (const season of Object.values(series.seasons)) { itemIndex = new Map<string, DownloadedItem>();
for (const episode of Object.values(season.episodes)) { const items = getAllDownloadedItems();
if (episode.item.Id === id) {
return episode; for (const item of items) {
} if (item.item.Id) {
} itemIndex.set(item.item.Id, item);
} }
} }
if (db.other?.[id]) { indexCacheVersion = cacheVersion;
return db.other[id]; }
}
return undefined; /**
* Get a downloaded item by its ID
* PERFORMANCE: Uses O(1) index lookup instead of O(n²) iteration
*/
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
ensureItemIndex();
return itemIndex!.get(id);
} }
/** /**
@@ -221,4 +268,5 @@ export function updateDownloadedItem(
*/ */
export function clearAllDownloadedItems(): void { export function clearAllDownloadedItems(): void {
saveDownloadsDatabase({ movies: {}, series: {}, other: {} }); saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
// saveDownloadsDatabase already invalidates caches
} }

View File

@@ -2,6 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type * as NotificationsType from "expo-notifications"; import type * as NotificationsType from "expo-notifications";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { storage } from "@/utils/mmkv";
// Conditionally import expo-notifications only on non-TV platforms // Conditionally import expo-notifications only on non-TV platforms
const Notifications = Platform.isTV const Notifications = Platform.isTV
@@ -67,6 +68,14 @@ export async function sendDownloadNotification(
): Promise<void> { ): Promise<void> {
if (Platform.isTV || !Notifications) return; 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 { try {
await Notifications.scheduleNotificationAsync({ await Notifications.scheduleNotificationAsync({
content: { content: {

View File

@@ -119,6 +119,9 @@
"settings": { "settings": {
"settings_title": "Settings", "settings_title": "Settings",
"log_out_button": "Log Out", "log_out_button": "Log Out",
"search_placeholder": "Search settings",
"search_results": "Results",
"search_no_results": "No matching settings",
"switch_user": { "switch_user": {
"title": "Switch User", "title": "Switch User",
"account": "Account", "account": "Account",
@@ -126,7 +129,24 @@
"current": "current" "current": "current"
}, },
"categories": { "categories": {
"title": "Categories" "title": "Categories",
"playback": "Playback",
"personalization": "Personalization",
"advanced": "Advanced"
},
"notifications": {
"title": "Notifications",
"disabled_title": "Notifications are off",
"disabled_description": "Allow notifications to get alerts about your downloads and more.",
"enable_button": "Enable notifications",
"events_title": "Notify me about",
"master": "Enable notifications",
"downloads": "Download completed / failed"
},
"account": {
"title": "Account",
"copy_token": "Copy token",
"copied": "Copied to clipboard"
}, },
"playback_controls": { "playback_controls": {
"title": "Playback & Controls" "title": "Playback & Controls"

View File

@@ -39,6 +39,8 @@
"node_modules", "node_modules",
"babel.config.js", "babel.config.js",
"metro.config.js", "metro.config.js",
"utils/jellyseerr/**/*" "utils/jellyseerr/**/*",
"**/*.test.ts",
"**/*.test.tsx"
] ]
} }

View File

@@ -294,6 +294,12 @@ export type Settings = {
openSubtitlesApiKey?: string; openSubtitlesApiKey?: string;
// TV-only: Inactivity timeout for auto-logout // TV-only: Inactivity timeout for auto-logout
inactivityTimeout: InactivityTimeout; inactivityTimeout: InactivityTimeout;
// Settings-redo additions (SP1)
notificationsEnabled: boolean;
notifyDownloads: boolean;
defaultLandingTab: "(home)" | "(search)" | "(favorites)" | "(libraries)";
downloadOnWifiOnly: boolean;
cellularBitrate?: Bitrate;
}; };
export interface Lockable<T> { export interface Lockable<T> {
@@ -395,6 +401,11 @@ export const defaultValues: Settings = {
audioTranscodeMode: AudioTranscodeMode.Auto, audioTranscodeMode: AudioTranscodeMode.Auto,
// TV-only: Inactivity timeout (disabled by default) // TV-only: Inactivity timeout (disabled by default)
inactivityTimeout: InactivityTimeout.Disabled, inactivityTimeout: InactivityTimeout.Disabled,
notificationsEnabled: true,
notifyDownloads: true,
defaultLandingTab: "(home)",
downloadOnWifiOnly: false,
cellularBitrate: undefined,
}; };
const loadSettings = (): Partial<Settings> => { const loadSettings = (): Partial<Settings> => {

View File

@@ -0,0 +1,23 @@
import { Platform } from "react-native";
/**
* TV-safe re-exports of `@expo/ui/community/bottom-sheet`.
*
* `@expo/ui` resolves its native bridge at module load via
* `requireNativeModule('ExpoUI')`, which does not exist on tvOS — a static
* top-level import would crash the whole expo-router route tree. We `require()`
* it lazily and only off-TV; on TV the exports are undefined, which is fine
* because every call site early-returns on `Platform.isTV`.
*/
type BottomSheetMod = typeof import("@expo/ui/community/bottom-sheet");
const mod: BottomSheetMod = Platform.isTV
? ({} as BottomSheetMod)
: (require("@expo/ui/community/bottom-sheet") as BottomSheetMod);
export const BottomSheetModal = mod.BottomSheetModal;
export const BottomSheetView = mod.BottomSheetView;
export const BottomSheetScrollView = mod.BottomSheetScrollView;
export const BottomSheetTextInput = mod.BottomSheetTextInput;
export type { BottomSheetMethods } from "@expo/ui/community/bottom-sheet";