Compare commits

..

5 Commits

Author SHA1 Message Date
Gauvain
4f03d7af3c fix(nav): release rapid-push guard when the push throws 2026-07-17 17:42:34 +02:00
Gauvain
1f7d24d63f fix(nav): block same-batch duplicate pushes from TV remote double-fire 2026-07-15 01:12:52 +02:00
Gauvain
7b15b15af3 fix(nav): clarify the focus-guard comment scope 2026-07-15 00:33:27 +02:00
Gauvain
f8b8cddbfa fix(navigation): dismiss the keyboard when using the header back button
Leaving a screen with the keyboard up (e.g. the Jellyseerr login) left it
lingering over the previous screen. Dismiss it before navigating back.
2026-07-15 00:32:53 +02:00
Gauvain
3bd7507462 fix(nav): drop duplicate pushes from rapid taps
Tapping an item twice before the pushed screen rendered stacked the
screen twice. A push blurs the source screen synchronously in the
navigation state, so a second tap sees an unfocused screen and is
dropped (focus-based guard, no timers).
2026-07-15 00:32:03 +02:00
13 changed files with 260 additions and 256 deletions

View File

@@ -1,8 +1,11 @@
import { ScrollView, View } from "react-native"; import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr"; import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function JellyseerrPluginPage() { export default function JellyseerrPluginPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
return ( return (
@@ -13,9 +16,12 @@ export default function JellyseerrPluginPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className='p-4'> <DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='p-4'
>
<JellyseerrSettings /> <JellyseerrSettings />
</View> </DisabledSetting>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,8 +1,11 @@
import { ScrollView, View } from "react-native"; import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks"; import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings";
export default function KefinTweaksPage() { export default function KefinTweaksPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
return ( return (
@@ -13,9 +16,12 @@ export default function KefinTweaksPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className='px-4'> <DisabledSetting
disabled={pluginSettings?.useKefinTweaks?.locked === true}
className='p-4'
>
<KefinTweaksSettings /> <KefinTweaksSettings />
</View> </DisabledSetting>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,5 +1,5 @@
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Linking, Linking,
@@ -14,24 +14,22 @@ import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function MarlinSearchPage() { export default function MarlinSearchPage() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || ""); const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const searchEngineLocked = pluginSettings?.searchEngine?.locked === true;
const marlinUrlLocked = pluginSettings?.marlinServerUrl?.locked === true;
// Effective (user/admin merged) URL, same source the search screen uses —
// the raw plugin value misses a user-configured Streamystats.
const hasStreamystats = !!settings?.streamyStatsServerUrl;
const onSave = (val: string) => { const onSave = (val: string) => {
updateSettings({ updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1), marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
@@ -43,8 +41,15 @@ export default function MarlinSearchPage() {
Linking.openURL("https://github.com/fredrikburmester/marlin-search"); Linking.openURL("https://github.com/fredrikburmester/marlin-search");
}; };
const disabled = useMemo(() => {
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
}, [pluginSettings]);
useEffect(() => { useEffect(() => {
if (!marlinUrlLocked) { if (!pluginSettings?.marlinServerUrl?.locked) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)} className='px-2'> <TouchableOpacity onPress={() => onSave(value)} className='px-2'>
@@ -55,7 +60,7 @@ export default function MarlinSearchPage() {
), ),
}); });
} }
}, [navigation, value, marlinUrlLocked, t]); }, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
if (!settings) return null; if (!settings) return null;
@@ -67,18 +72,19 @@ export default function MarlinSearchPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className='px-4'> <DisabledSetting disabled={disabled} className='px-4'>
<ListGroup> <ListGroup>
{/* disabledByAdmin renders the "Disabled by admin" notice as the row's <DisabledSetting
subtitle (same pattern as the Streamystats settings) — no clipping. */} disabled={
pluginSettings?.searchEngine?.locked === true ||
!!pluginSettings?.streamyStatsServerUrl?.value
}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem <ListItem
title={t( title={t(
"home.settings.plugins.marlin_search.enable_marlin_search", "home.settings.plugins.marlin_search.enable_marlin_search",
)} )}
disabledByAdmin={searchEngineLocked}
// Streamystats owns the search engine while configured — block the
// row tap too, not just the Switch, so it can't force "Jellyfin".
disabled={hasStreamystats}
onPress={() => { onPress={() => {
updateSettings({ searchEngine: "Jellyfin" }); updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
@@ -86,23 +92,32 @@ export default function MarlinSearchPage() {
> >
<Switch <Switch
value={settings.searchEngine === "Marlin"} value={settings.searchEngine === "Marlin"}
disabled={searchEngineLocked || hasStreamystats} disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
onValueChange={(val) => { onValueChange={(value) => {
updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" }); updateSettings({
searchEngine: value ? "Marlin" : "Jellyfin",
});
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
}} }}
/> />
</ListItem> </ListItem>
</DisabledSetting>
</ListGroup> </ListGroup>
<ListGroup className='mt-2'> <DisabledSetting
<ListItem disabled={pluginSettings?.marlinServerUrl?.locked === true}
title={t("home.settings.plugins.marlin_search.url")} showText={!pluginSettings?.searchEngine?.locked}
disabledByAdmin={marlinUrlLocked} className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
> >
<View
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
>
<Text className='mr-4'>
{t("home.settings.plugins.marlin_search.url")}
</Text>
<TextInput <TextInput
editable={!marlinUrlLocked && settings.searchEngine === "Marlin"} editable={settings.searchEngine === "Marlin"}
className='text-white text-right flex-1' className='text-white'
placeholder={t( placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder", "home.settings.plugins.marlin_search.server_url_placeholder",
)} )}
@@ -113,16 +128,15 @@ export default function MarlinSearchPage() {
textContentType='URL' textContentType='URL'
onChangeText={(text) => setValue(text)} onChangeText={(text) => setValue(text)}
/> />
</ListItem> </View>
</ListGroup> </DisabledSetting>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "} {t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}> <Text className='text-blue-500' onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")} {t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text> </Text>
</Text> </Text>
</View> </DisabledSetting>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,28 +1,9 @@
import { useCallback } from "react"; import { Platform, ScrollView, View } from "react-native";
import { useTranslation } from "react-i18next";
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 { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { PluginSettings } from "@/components/settings/PluginSettings"; import { PluginSettings } from "@/components/settings/PluginSettings";
import { useSettings } from "@/utils/atoms/settings";
export default function PluginsPage() { export default function PluginsPage() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { refreshStreamyfinPluginSettings } = useSettings();
const handleRefreshFromServer = useCallback(async () => {
// Returns undefined when the API call fails (handled internally).
const refreshed = await refreshStreamyfinPluginSettings();
if (refreshed) {
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
} else {
toast.error(
t("home.settings.plugins.streamystats.toasts.refresh_failed"),
);
}
}, [refreshStreamyfinPluginSettings, t]);
return ( return (
<ScrollView <ScrollView
@@ -37,17 +18,6 @@ export default function PluginsPage() {
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
> >
<PluginSettings /> <PluginSettings />
{/* Pulls the centralised Streamyfin plugin settings for every plugin,
so it lives on the plugins index rather than inside Streamystats. */}
<TouchableOpacity
onPress={handleRefreshFromServer}
className='py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-blue-500'>
{t("home.settings.plugins.streamystats.refresh_from_server")}
</Text>
</TouchableOpacity>
</View> </View>
</ScrollView> </ScrollView>
); );

View File

@@ -22,7 +22,12 @@ export default function StreamystatsPage() {
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings(); const {
settings,
updateSettings,
pluginSettings,
refreshStreamyfinPluginSettings,
} = useSettings();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
// Local state for all editable fields // Local state for all editable fields
@@ -44,21 +49,7 @@ export default function StreamystatsPage() {
); );
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true; const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
const searchLocked = pluginSettings?.searchEngine?.locked === true; const isStreamystatsEnabled = !!url;
const movieRecsLocked =
pluginSettings?.streamyStatsMovieRecommendations?.locked === true;
const seriesRecsLocked =
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true;
const promotedWatchlistsLocked =
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true;
const hideWatchlistsTabLocked =
pluginSettings?.hideWatchlistsTab?.locked === true;
// The input renders the locked admin URL; enablement must follow the same
// effective value or every toggle stays disabled until local state syncs.
const effectiveUrl = isUrlLocked
? (settings?.streamyStatsServerUrl ?? "")
: url;
const isStreamystatsEnabled = !!effectiveUrl;
const onSave = useCallback(() => { const onSave = useCallback(() => {
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url; const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
@@ -122,6 +113,17 @@ export default function StreamystatsPage() {
Linking.openURL("https://github.com/fredrikburmester/streamystats"); Linking.openURL("https://github.com/fredrikburmester/streamystats");
}; };
const handleRefreshFromServer = useCallback(async () => {
const newPluginSettings = await refreshStreamyfinPluginSettings();
// Update local state with new values
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
setUrl(newUrl);
if (newUrl) {
setUseForSearch(true);
}
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
}, [refreshStreamyfinPluginSettings, t]);
if (!settings) return null; if (!settings) return null;
return ( return (
@@ -144,7 +146,7 @@ export default function StreamystatsPage() {
placeholder={t( placeholder={t(
"home.settings.plugins.streamystats.server_url_placeholder", "home.settings.plugins.streamystats.server_url_placeholder",
)} )}
value={effectiveUrl} value={url}
keyboardType='url' keyboardType='url'
returnKeyType='done' returnKeyType='done'
autoCapitalize='none' autoCapitalize='none'
@@ -169,18 +171,11 @@ export default function StreamystatsPage() {
> >
<ListItem <ListItem
title={t("home.settings.plugins.streamystats.enable_search")} title={t("home.settings.plugins.streamystats.enable_search")}
disabledByAdmin={searchLocked} disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
> >
{/* Locked controls show the live admin value and can't be toggled —
local form state would let the switch flip while the write guard
drops the change. */}
<Switch <Switch
value={ value={useForSearch}
searchLocked disabled={!isStreamystatsEnabled}
? settings?.searchEngine === "Streamystats"
: useForSearch
}
disabled={!isStreamystatsEnabled || searchLocked}
onValueChange={setUseForSearch} onValueChange={setUseForSearch}
/> />
</ListItem> </ListItem>
@@ -188,62 +183,52 @@ export default function StreamystatsPage() {
title={t( title={t(
"home.settings.plugins.streamystats.enable_movie_recommendations", "home.settings.plugins.streamystats.enable_movie_recommendations",
)} )}
disabledByAdmin={movieRecsLocked} disabledByAdmin={
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
}
> >
<Switch <Switch
value={ value={movieRecs}
movieRecsLocked
? (settings?.streamyStatsMovieRecommendations ?? false)
: movieRecs
}
onValueChange={setMovieRecs} onValueChange={setMovieRecs}
disabled={!isStreamystatsEnabled || movieRecsLocked} disabled={!isStreamystatsEnabled}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t( title={t(
"home.settings.plugins.streamystats.enable_series_recommendations", "home.settings.plugins.streamystats.enable_series_recommendations",
)} )}
disabledByAdmin={seriesRecsLocked} disabledByAdmin={
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
}
> >
<Switch <Switch
value={ value={seriesRecs}
seriesRecsLocked
? (settings?.streamyStatsSeriesRecommendations ?? false)
: seriesRecs
}
onValueChange={setSeriesRecs} onValueChange={setSeriesRecs}
disabled={!isStreamystatsEnabled || seriesRecsLocked} disabled={!isStreamystatsEnabled}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t( title={t(
"home.settings.plugins.streamystats.enable_promoted_watchlists", "home.settings.plugins.streamystats.enable_promoted_watchlists",
)} )}
disabledByAdmin={promotedWatchlistsLocked} disabledByAdmin={
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
}
> >
<Switch <Switch
value={ value={promotedWatchlists}
promotedWatchlistsLocked
? (settings?.streamyStatsPromotedWatchlists ?? false)
: promotedWatchlists
}
onValueChange={setPromotedWatchlists} onValueChange={setPromotedWatchlists}
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked} disabled={!isStreamystatsEnabled}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")} title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
disabledByAdmin={hideWatchlistsTabLocked} disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
> >
<Switch <Switch
value={ value={hideWatchlistsTab}
hideWatchlistsTabLocked
? (settings?.hideWatchlistsTab ?? false)
: hideWatchlistsTab
}
onValueChange={setHideWatchlistsTab} onValueChange={setHideWatchlistsTab}
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked} disabled={!isStreamystatsEnabled}
/> />
</ListItem> </ListItem>
</ListGroup> </ListGroup>
@@ -251,6 +236,15 @@ export default function StreamystatsPage() {
{t("home.settings.plugins.streamystats.home_sections_hint")} {t("home.settings.plugins.streamystats.home_sections_hint")}
</Text> </Text>
<TouchableOpacity
onPress={handleRefreshFromServer}
className='mt-6 py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-blue-500'>
{t("home.settings.plugins.streamystats.refresh_from_server")}
</Text>
</TouchableOpacity>
{/* Disable button - only show if URL is not locked and Streamystats is enabled */} {/* Disable button - only show if URL is not locked and Streamystats is enabled */}
{!isUrlLocked && isStreamystatsEnabled && ( {!isUrlLocked && isStreamystatsEnabled && (
<TouchableOpacity <TouchableOpacity

View File

@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur"; import { BlurView, type BlurViewProps } from "expo-blur";
import { Platform } from "react-native"; import { Keyboard, Platform } from "react-native";
import { Pressable, type PressableProps } from "react-native-gesture-handler"; import { Pressable, type PressableProps } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
@@ -16,10 +16,17 @@ export const HeaderBackButton: React.FC<Props> = ({
}) => { }) => {
const router = useRouter(); const router = useRouter();
// Dismiss the keyboard before navigating — otherwise it lingers over the
// previous screen (e.g. leaving the Jellyseerr login while typing).
const handleBack = () => {
Keyboard.dismiss();
router.back();
};
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
return ( return (
<Pressable <Pressable
onPress={() => router.back()} onPress={handleBack}
className='flex items-center justify-center w-9 h-9' className='flex items-center justify-center w-9 h-9'
{...pressableProps} {...pressableProps}
> >
@@ -30,7 +37,7 @@ export const HeaderBackButton: React.FC<Props> = ({
if (background === "transparent" && Platform.OS !== "android") if (background === "transparent" && Platform.OS !== "android")
return ( return (
<Pressable onPress={() => router.back()} {...pressableProps}> <Pressable onPress={handleBack} {...pressableProps}>
<BlurView <BlurView
{...props} {...props}
intensity={100} intensity={100}
@@ -48,7 +55,7 @@ export const HeaderBackButton: React.FC<Props> = ({
return ( return (
<Pressable <Pressable
onPress={() => router.back()} onPress={handleBack}
className=' rounded-full p-2' className=' rounded-full p-2'
{...pressableProps} {...pressableProps}
> >

View File

@@ -1,6 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { PropsWithChildren, ReactNode } from "react"; import type { PropsWithChildren, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
@@ -33,10 +32,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
disabledByAdmin = false, disabledByAdmin = false,
...viewProps ...viewProps
}) => { }) => {
const { t } = useTranslation(); const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
const effectiveSubtitle = disabledByAdmin
? t("home.settings.disabled_by_admin")
: subtitle;
const isDisabled = disabled || disabledByAdmin; const isDisabled = disabled || disabledByAdmin;
if (onPress) if (onPress)
return ( return (

View File

@@ -20,10 +20,7 @@ export const JellyseerrSettings = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings } = useSettings();
// Only the server URL is admin-lockable — the password stays editable so
// the user can still sign in to the admin-pinned Jellyseerr server.
const urlLocked = pluginSettings?.jellyseerrServerUrl?.locked === true;
const [jellyseerrPassword, setJellyseerrPassword] = useState< const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined string | undefined
@@ -33,25 +30,22 @@ export const JellyseerrSettings = () => {
string | undefined string | undefined
>(settings?.jellyseerrServerUrl || undefined); >(settings?.jellyseerrServerUrl || undefined);
// When the URL is admin-pinned, ignore any locally-typed value so the login
// targets the same server the (read-only) input displays.
const effectiveServerUrl = urlLocked
? settings?.jellyseerrServerUrl
: jellyseerrServerUrl || settings?.jellyseerrServerUrl;
const loginToJellyseerrMutation = useMutation({ const loginToJellyseerrMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!effectiveServerUrl) throw new Error("Missing server url"); if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
throw new Error("Missing server url");
if (!user?.Name) if (!user?.Name)
throw new Error("Missing required information for login"); throw new Error("Missing required information for login");
const jellyseerrTempApi = new JellyseerrApi(effectiveServerUrl); const jellyseerrTempApi = new JellyseerrApi(
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
);
const testResult = await jellyseerrTempApi.test(); const testResult = await jellyseerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url"); if (!testResult.isValid) throw new Error("Invalid server url");
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || ""); return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
}, },
onSuccess: (user) => { onSuccess: (user) => {
setJellyseerrUser(user); setJellyseerrUser(user);
updateSettings({ jellyseerrServerUrl: effectiveServerUrl }); updateSettings({ jellyseerrServerUrl });
}, },
onError: () => { onError: () => {
toast.error(t("jellyseerr.failed_to_login")); toast.error(t("jellyseerr.failed_to_login"));
@@ -121,7 +115,6 @@ export const JellyseerrSettings = () => {
</> </>
) : ( ) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'> <View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<View style={{ opacity: urlLocked ? 0.5 : 1 }}>
<Text className='font-bold mb-1'> <Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")} {t("home.settings.plugins.jellyseerr.server_url")}
</Text> </Text>
@@ -135,11 +128,7 @@ export const JellyseerrSettings = () => {
placeholder={t( placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder", "home.settings.plugins.jellyseerr.server_url_placeholder",
)} )}
value={ value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
urlLocked
? settings?.jellyseerrServerUrl
: (jellyseerrServerUrl ?? settings?.jellyseerrServerUrl)
}
defaultValue={ defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
} }
@@ -148,14 +137,8 @@ export const JellyseerrSettings = () => {
autoCapitalize='none' autoCapitalize='none'
textContentType='URL' textContentType='URL'
onChangeText={setjellyseerrServerUrl} onChangeText={setjellyseerrServerUrl}
editable={!urlLocked && !loginToJellyseerrMutation.isPending} editable={!loginToJellyseerrMutation.isPending}
/> />
{urlLocked && (
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.disabled_by_admin")}
</Text>
)}
</View>
<View> <View>
<Text className='font-bold mb-2'> <Text className='font-bold mb-2'>
{t("home.settings.plugins.jellyseerr.password")} {t("home.settings.plugins.jellyseerr.password")}

View File

@@ -1,28 +1,33 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Switch } from "react-native"; import { Switch, Text, View } from "react-native";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const KefinTweaksSettings = () => { export const KefinTweaksSettings = () => {
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const isEnabled = settings?.useKefinTweaks ?? false; const isEnabled = settings?.useKefinTweaks ?? false;
const locked = pluginSettings?.useKefinTweaks?.locked === true;
return ( return (
<ListGroup> <View className=''>
<ListItem <View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
title={t("home.settings.plugins.kefinTweaks.watchlist_enabler")} <Text className='text-xs text-red-600 mb-2'>
disabledByAdmin={locked} {t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
> </Text>
<View className='flex flex-row items-center justify-between mt-2'>
<Text className='text-white'>
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
</Text>
<Switch <Switch
value={isEnabled} value={isEnabled}
disabled={locked}
onValueChange={(value) => updateSettings({ useKefinTweaks: value })} onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
trackColor={{ false: "#555", true: "purple" }}
thumbColor={isEnabled ? "#fff" : "#ccc"}
/> />
</ListItem> </View>
</ListGroup> </View>
</View>
); );
}; };

View File

@@ -20,12 +20,7 @@ export const PluginSettings = () => {
> >
<ListItem <ListItem
onPress={() => router.push("/settings/plugins/jellyseerr/page")} onPress={() => router.push("/settings/plugins/jellyseerr/page")}
title='Jellyseerr' title={"Jellyseerr"}
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/streamystats/page")}
title='Streamystats'
showArrow showArrow
/> />
<ListItem <ListItem
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
title='Marlin Search' title='Marlin Search'
showArrow showArrow
/> />
<ListItem
onPress={() => router.push("/settings/plugins/streamystats/page")}
title='Streamystats'
showArrow
/>
<ListItem <ListItem
onPress={() => router.push("/settings/plugins/kefinTweaks/page")} onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
title='KefinTweaks' title='KefinTweaks'

View File

@@ -1,13 +1,19 @@
// Imported from expo-router's bundled copy, NOT "@react-navigation/*": as of
// SDK 56 expo-router's Metro check rejects direct @react-navigation imports.
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react"; import { NavigationContext } from "expo-router/react-navigation";
import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
/** /**
* Drop-in replacement for expo-router's useRouter that automatically * Drop-in replacement for expo-router's useRouter that automatically
* preserves offline state across navigation. * preserves offline state across navigation and guards against duplicate
* screens from rapid taps.
* *
* - For object-form navigation, automatically adds offline=true when in offline context * - For object-form navigation, automatically adds offline=true when in offline context
* - For string URLs, passes through unchanged (caller handles offline param) * - For string URLs, passes through unchanged (caller handles offline param)
* - push() is a no-op while the source screen is not focused, so taps fired
* before the pushed screen has rendered (slow devices) can't stack duplicates
* *
* @example * @example
* import useRouter from "@/hooks/useAppRouter"; * import useRouter from "@/hooks/useAppRouter";
@@ -19,8 +25,37 @@ export function useAppRouter() {
const router = useRouter(); const router = useRouter();
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
// Optional: undefined when used outside a navigator (root layout, providers).
// When present it reflects the focus state of the screen this hook lives in.
const navigation = useContext(NavigationContext);
// Synchronous re-entry guard for TV: a single remote "select" on Android TV
// can fire onPress more than once within the same JS batch
// (react-native-tvos#110/#138), BEFORE react-navigation commits the pushed
// route — so isFocused() still reads true for the duplicate. The ref flips
// synchronously on the first push and resets when the screen regains focus.
const pushInFlightRef = useRef(false);
useEffect(() => {
if (!navigation) return;
return navigation.addListener("focus", () => {
pushInFlightRef.current = false;
});
}, [navigation]);
const push = useCallback( const push = useCallback(
(href: Parameters<typeof router.push>[0]) => { (href: Parameters<typeof router.push>[0]) => {
// Rapid-push guard: a push blurs the source screen synchronously in the
// navigation state (only the native render is slow). Any further push from
// this screen — duplicate or not — is dropped until focus returns, so taps
// fired before the pushed screen renders can't stack screens.
// No navigation context => nothing to guard (deep-link pushes from root).
if (navigation) {
if (navigation.isFocused?.() === false || pushInFlightRef.current)
return;
pushInFlightRef.current = true;
}
try {
if (typeof href === "string") { if (typeof href === "string") {
router.push(href as any); router.push(href as any);
} else { } else {
@@ -35,8 +70,14 @@ export function useAppRouter() {
}, },
} as any); } as any);
} }
} catch (e) {
// A push that throws never blurs the screen, so the focus listener
// would never reset the guard — release it here to avoid a stuck lock.
pushInFlightRef.current = false;
throw e;
}
}, },
[router, isOffline], [router, isOffline, navigation],
); );
const replace = useCallback( const replace = useCallback(

View File

@@ -118,7 +118,6 @@
}, },
"settings": { "settings": {
"settings_title": "Settings", "settings_title": "Settings",
"disabled_by_admin": "Disabled by admin",
"log_out_button": "Log out", "log_out_button": "Log out",
"switch_user": { "switch_user": {
"title": "Switch user", "title": "Switch user",
@@ -371,7 +370,6 @@
"toasts": { "toasts": {
"saved": "Saved", "saved": "Saved",
"refreshed": "Settings refreshed from server", "refreshed": "Settings refreshed from server",
"refresh_failed": "Couldn't refresh settings from server",
"disabled": "Streamystats disabled" "disabled": "Streamystats disabled"
}, },
"refresh_from_server": "Refresh settings from server" "refresh_from_server": "Refresh settings from server"

View File

@@ -449,6 +449,11 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
const hasMeaningfulSettingValue = (value: unknown) => const hasMeaningfulSettingValue = (value: unknown) =>
value !== undefined && value !== null && value !== ""; value !== undefined && value !== null && value !== "";
const getEffectiveSettingValue = <K extends keyof Settings>(
settings: Partial<Settings> | null | undefined,
settingsKey: K,
) => settings?.[settingsKey] ?? defaultValues[settingsKey];
export const useSettings = () => { export const useSettings = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const [_settings, setSettings] = useAtom(settingsAtom); const [_settings, setSettings] = useAtom(settingsAtom);
@@ -505,17 +510,7 @@ export const useSettings = () => {
if (!_settings) { if (!_settings) {
return; return;
} }
// Admin-locked settings are enforced at write time too: a control that const hasChanges = Object.entries(update).some(
// isn't disabled in the UI must not persist a value the admin pinned.
// The read memo already overrides locked keys, but without this guard the
// write would silently land in user storage and resurface once unlocked.
const sanitizedUpdate = Object.fromEntries(
Object.entries(update).filter(
([key]) => pluginSettings?.[key as keyof Settings]?.locked !== true,
),
) as Partial<Settings>;
const hasChanges = Object.entries(sanitizedUpdate).some(
([key, value]) => _settings[key as keyof Settings] !== value, ([key, value]) => _settings[key as keyof Settings] !== value,
); );
@@ -524,7 +519,7 @@ export const useSettings = () => {
const newSettings = { const newSettings = {
...defaultValues, ...defaultValues,
..._settings, ..._settings,
...sanitizedUpdate, ...update,
} as Settings; } as Settings;
setSettings(newSettings); setSettings(newSettings);
saveSettings(newSettings); saveSettings(newSettings);
@@ -547,24 +542,13 @@ export const useSettings = () => {
// Normalize object-typed settings from plugin (plain primitive → { key, value }) // Normalize object-typed settings from plugin (plain primitive → { key, value })
value = normalizePluginValue(settingsKey, value); value = normalizePluginValue(settingsKey, value);
// When unlocked, keep the user's value only if they explicitly diverged const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
// from the app default. Otherwise the plugin value is the admin's
// default and must win over the hardcoded app default — e.g. a toggle
// that was always locked then unlocked should reflect the plugin
// default, not the app's `false`. Object-typed settings compare by
// reference, so their behaviour is unchanged.
const userValue = _settings?.[settingsKey];
const userDiverged =
hasMeaningfulSettingValue(userValue) &&
userValue !== defaultValues[settingsKey];
(acc as any)[settingsKey] = locked (acc as any)[settingsKey] = locked
? value ? value
: userDiverged : hasMeaningfulSettingValue(effectiveValue)
? userValue ? effectiveValue
: hasMeaningfulSettingValue(value) : value;
? value
: defaultValues[settingsKey];
} }
return acc; return acc;
}, {}); }, {});