Compare commits

..

9 Commits

Author SHA1 Message Date
Gauvain
fd4215407d fix(review): effective streamystats gate, honest refresh toast, locked jellyseerr url, i18n admin notice 2026-07-17 17:41:12 +02:00
Gauvain
b7bdd99cdd fix(streamystats): derive control enablement from the effective server URL 2026-07-15 00:34:19 +02:00
Gauvain
6ba1111794 fix(settings): apply the plugin (admin) default when a setting is unlocked
A setting that was always locked then unlocked showed the app's hardcoded
default instead of the plugin's configured default: the unlocked branch
treated the persisted app default as a meaningful user value, so the plugin
value was effectively dead code for any key with a non-empty app default.

Keep the user's value only when it diverges from the app default; otherwise
the plugin (admin) default wins over the hardcoded app default. Shared via
useSettings, so mobile and TV are both fixed.
2026-07-15 00:33:56 +02:00
Gauvain
9fea9015eb refactor(kefin-tweaks): use the standard ListItem/disabledByAdmin pattern
Rebuild the KefinTweaks toggle on ListGroup + ListItem like the other
plugin settings: drop the bespoke card, the red label, the custom switch
colours, and the `t("Watchlist On")`/`t("Watchlist Off")` literal-as-key
strings. The admin lock now shows via the ListItem subtitle instead of
wrapping the whole screen in DisabledSetting.
2026-07-15 00:33:51 +02:00
Gauvain
500da94583 fix(jellyseerr): lock only the server URL, keep the password editable
The page greyed out the whole settings screen when the admin locked the
server URL, so the user couldn't even type their password to sign in to
the pinned server. Now only the URL field is disabled (greyed + "Disabled
by admin"); the password input stays editable.
2026-07-15 00:33:42 +02:00
Gauvain
fb8f11460b refactor(settings): move "refresh from server" to the plugins index
The button pulls the centralised Streamyfin plugin settings for every
plugin, so it belongs on the plugins list page rather than buried inside
the Streamystats screen.
2026-07-15 00:33:35 +02:00
Gauvain
112d9bef96 fix(marlin-search): show the admin-lock notice via the ListItem subtitle
The "Disabled by admin" notice was rendered by DisabledSetting inside the
rounded, overflow-hidden card, which clipped the text. Switch to the same
pattern as the Streamystats settings: plain ListItems with the
`disabledByAdmin` prop, so the notice renders as the row subtitle and the
URL/toggle disable per-field.
2026-07-15 00:33:30 +02:00
Gauvain
692e02e42f fix(settings): enforce admin-locked settings at write time
updateSettings persisted any key into user storage, including ones the
admin locked via the Streamyfin plugin. The read memo already overrides
locked keys at runtime, but the write still landed in storage and
several settings screens never disable their controls, so locked
settings appeared changeable. Strip locked keys before persisting.
2026-07-15 00:33:24 +02:00
Gauvain
8618e2d6c3 fix(settings): restore plugin list order
Jellyseerr / Streamystats / Marlin Search rows were reordered by
mistake; put them back in the original order.
2026-07-15 00:33:17 +02:00
13 changed files with 256 additions and 260 deletions

View File

@@ -1,11 +1,8 @@
import { ScrollView } from "react-native"; import { ScrollView, View } 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 (
@@ -16,12 +13,9 @@ export default function JellyseerrPluginPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<DisabledSetting <View className='p-4'>
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='p-4'
>
<JellyseerrSettings /> <JellyseerrSettings />
</DisabledSetting> </View>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,11 +1,8 @@
import { ScrollView } from "react-native"; import { ScrollView, View } 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 (
@@ -16,12 +13,9 @@ export default function KefinTweaksPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<DisabledSetting <View className='px-4'>
disabled={pluginSettings?.useKefinTweaks?.locked === true}
className='p-4'
>
<KefinTweaksSettings /> <KefinTweaksSettings />
</DisabledSetting> </View>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,5 +1,5 @@
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Linking, Linking,
@@ -14,22 +14,24 @@ 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),
@@ -41,15 +43,8 @@ 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 (!pluginSettings?.marlinServerUrl?.locked) { if (!marlinUrlLocked) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)} className='px-2'> <TouchableOpacity onPress={() => onSave(value)} className='px-2'>
@@ -60,7 +55,7 @@ export default function MarlinSearchPage() {
), ),
}); });
} }
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]); }, [navigation, value, marlinUrlLocked, t]);
if (!settings) return null; if (!settings) return null;
@@ -72,19 +67,18 @@ export default function MarlinSearchPage() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<DisabledSetting disabled={disabled} className='px-4'> <View className='px-4'>
<ListGroup> <ListGroup>
<DisabledSetting {/* disabledByAdmin renders the "Disabled by admin" notice as the row's
disabled={ subtitle (same pattern as the Streamystats settings) — no clipping. */}
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"] });
@@ -92,32 +86,23 @@ export default function MarlinSearchPage() {
> >
<Switch <Switch
value={settings.searchEngine === "Marlin"} value={settings.searchEngine === "Marlin"}
disabled={!!pluginSettings?.streamyStatsServerUrl?.value} disabled={searchEngineLocked || hasStreamystats}
onValueChange={(value) => { onValueChange={(val) => {
updateSettings({ updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
searchEngine: value ? "Marlin" : "Jellyfin",
});
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
}} }}
/> />
</ListItem> </ListItem>
</DisabledSetting>
</ListGroup> </ListGroup>
<DisabledSetting <ListGroup className='mt-2'>
disabled={pluginSettings?.marlinServerUrl?.locked === true} <ListItem
showText={!pluginSettings?.searchEngine?.locked} title={t("home.settings.plugins.marlin_search.url")}
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4' disabledByAdmin={marlinUrlLocked}
> >
<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={settings.searchEngine === "Marlin"} editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
className='text-white' className='text-white text-right flex-1'
placeholder={t( placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder", "home.settings.plugins.marlin_search.server_url_placeholder",
)} )}
@@ -128,15 +113,16 @@ export default function MarlinSearchPage() {
textContentType='URL' textContentType='URL'
onChangeText={(text) => setValue(text)} onChangeText={(text) => setValue(text)}
/> />
</View> </ListItem>
</DisabledSetting> </ListGroup>
<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>
</DisabledSetting> </View>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,9 +1,28 @@
import { Platform, ScrollView, View } from "react-native"; import { useCallback } from "react";
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
@@ -18,6 +37,17 @@ 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,12 +22,7 @@ export default function StreamystatsPage() {
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { const { settings, updateSettings, pluginSettings } = useSettings();
settings,
updateSettings,
pluginSettings,
refreshStreamyfinPluginSettings,
} = useSettings();
const queryClient = useNetworkAwareQueryClient(); const queryClient = useNetworkAwareQueryClient();
// Local state for all editable fields // Local state for all editable fields
@@ -49,7 +44,21 @@ export default function StreamystatsPage() {
); );
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true; const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
const isStreamystatsEnabled = !!url; const searchLocked = pluginSettings?.searchEngine?.locked === true;
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;
@@ -113,17 +122,6 @@ 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 (
@@ -146,7 +144,7 @@ export default function StreamystatsPage() {
placeholder={t( placeholder={t(
"home.settings.plugins.streamystats.server_url_placeholder", "home.settings.plugins.streamystats.server_url_placeholder",
)} )}
value={url} value={effectiveUrl}
keyboardType='url' keyboardType='url'
returnKeyType='done' returnKeyType='done'
autoCapitalize='none' autoCapitalize='none'
@@ -171,11 +169,18 @@ export default function StreamystatsPage() {
> >
<ListItem <ListItem
title={t("home.settings.plugins.streamystats.enable_search")} title={t("home.settings.plugins.streamystats.enable_search")}
disabledByAdmin={pluginSettings?.searchEngine?.locked === true} disabledByAdmin={searchLocked}
> >
{/* 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={useForSearch} value={
disabled={!isStreamystatsEnabled} searchLocked
? settings?.searchEngine === "Streamystats"
: useForSearch
}
disabled={!isStreamystatsEnabled || searchLocked}
onValueChange={setUseForSearch} onValueChange={setUseForSearch}
/> />
</ListItem> </ListItem>
@@ -183,52 +188,62 @@ export default function StreamystatsPage() {
title={t( title={t(
"home.settings.plugins.streamystats.enable_movie_recommendations", "home.settings.plugins.streamystats.enable_movie_recommendations",
)} )}
disabledByAdmin={ disabledByAdmin={movieRecsLocked}
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
}
> >
<Switch <Switch
value={movieRecs} value={
movieRecsLocked
? (settings?.streamyStatsMovieRecommendations ?? false)
: movieRecs
}
onValueChange={setMovieRecs} onValueChange={setMovieRecs}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || movieRecsLocked}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t( title={t(
"home.settings.plugins.streamystats.enable_series_recommendations", "home.settings.plugins.streamystats.enable_series_recommendations",
)} )}
disabledByAdmin={ disabledByAdmin={seriesRecsLocked}
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
}
> >
<Switch <Switch
value={seriesRecs} value={
seriesRecsLocked
? (settings?.streamyStatsSeriesRecommendations ?? false)
: seriesRecs
}
onValueChange={setSeriesRecs} onValueChange={setSeriesRecs}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || seriesRecsLocked}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t( title={t(
"home.settings.plugins.streamystats.enable_promoted_watchlists", "home.settings.plugins.streamystats.enable_promoted_watchlists",
)} )}
disabledByAdmin={ disabledByAdmin={promotedWatchlistsLocked}
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
}
> >
<Switch <Switch
value={promotedWatchlists} value={
promotedWatchlistsLocked
? (settings?.streamyStatsPromotedWatchlists ?? false)
: promotedWatchlists
}
onValueChange={setPromotedWatchlists} onValueChange={setPromotedWatchlists}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")} title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true} disabledByAdmin={hideWatchlistsTabLocked}
> >
<Switch <Switch
value={hideWatchlistsTab} value={
hideWatchlistsTabLocked
? (settings?.hideWatchlistsTab ?? false)
: hideWatchlistsTab
}
onValueChange={setHideWatchlistsTab} onValueChange={setHideWatchlistsTab}
disabled={!isStreamystatsEnabled} disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
/> />
</ListItem> </ListItem>
</ListGroup> </ListGroup>
@@ -236,15 +251,6 @@ 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 { Keyboard, Platform } from "react-native"; import { 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,17 +16,10 @@ 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={handleBack} onPress={() => router.back()}
className='flex items-center justify-center w-9 h-9' className='flex items-center justify-center w-9 h-9'
{...pressableProps} {...pressableProps}
> >
@@ -37,7 +30,7 @@ export const HeaderBackButton: React.FC<Props> = ({
if (background === "transparent" && Platform.OS !== "android") if (background === "transparent" && Platform.OS !== "android")
return ( return (
<Pressable onPress={handleBack} {...pressableProps}> <Pressable onPress={() => router.back()} {...pressableProps}>
<BlurView <BlurView
{...props} {...props}
intensity={100} intensity={100}
@@ -55,7 +48,7 @@ export const HeaderBackButton: React.FC<Props> = ({
return ( return (
<Pressable <Pressable
onPress={handleBack} onPress={() => router.back()}
className=' rounded-full p-2' className=' rounded-full p-2'
{...pressableProps} {...pressableProps}
> >

View File

@@ -1,5 +1,6 @@
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";
@@ -32,7 +33,10 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
disabledByAdmin = false, disabledByAdmin = false,
...viewProps ...viewProps
}) => { }) => {
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle; const { t } = useTranslation();
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,7 +20,10 @@ export const JellyseerrSettings = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { settings, updateSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = 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
@@ -30,22 +33,25 @@ 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 (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl) if (!effectiveServerUrl) throw new Error("Missing server url");
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( const jellyseerrTempApi = new JellyseerrApi(effectiveServerUrl);
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 }); updateSettings({ jellyseerrServerUrl: effectiveServerUrl });
}, },
onError: () => { onError: () => {
toast.error(t("jellyseerr.failed_to_login")); toast.error(t("jellyseerr.failed_to_login"));
@@ -115,6 +121,7 @@ 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>
@@ -128,7 +135,11 @@ export const JellyseerrSettings = () => {
placeholder={t( placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder", "home.settings.plugins.jellyseerr.server_url_placeholder",
)} )}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl} value={
urlLocked
? settings?.jellyseerrServerUrl
: (jellyseerrServerUrl ?? settings?.jellyseerrServerUrl)
}
defaultValue={ defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
} }
@@ -137,8 +148,14 @@ export const JellyseerrSettings = () => {
autoCapitalize='none' autoCapitalize='none'
textContentType='URL' textContentType='URL'
onChangeText={setjellyseerrServerUrl} onChangeText={setjellyseerrServerUrl}
editable={!loginToJellyseerrMutation.isPending} editable={!urlLocked && !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,33 +1,28 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Switch, Text, View } from "react-native"; import { Switch } 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 } = useSettings(); const { settings, updateSettings, pluginSettings } = 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 (
<View className=''> <ListGroup>
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'> <ListItem
<Text className='text-xs text-red-600 mb-2'> title={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")} disabledByAdmin={locked}
</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"}
/> />
</View> </ListItem>
</View> </ListGroup>
</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/marlin-search/page")}
title='Marlin Search'
showArrow showArrow
/> />
<ListItem <ListItem
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
title='Streamystats' title='Streamystats'
showArrow showArrow
/> />
<ListItem
onPress={() => router.push("/settings/plugins/marlin-search/page")}
title='Marlin Search'
showArrow
/>
<ListItem <ListItem
onPress={() => router.push("/settings/plugins/kefinTweaks/page")} onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
title='KefinTweaks' title='KefinTweaks'

View File

@@ -1,19 +1,13 @@
// 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 { NavigationContext } from "expo-router/react-navigation"; import { useCallback, useMemo } from "react";
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 and guards against duplicate * preserves offline state across navigation.
* 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";
@@ -25,37 +19,8 @@ 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 {
@@ -70,14 +35,8 @@ 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, navigation], [router, isOffline],
); );
const replace = useCallback( const replace = useCallback(

View File

@@ -118,6 +118,7 @@
}, },
"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",
@@ -370,6 +371,7 @@
"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,11 +449,6 @@ 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);
@@ -510,7 +505,17 @@ export const useSettings = () => {
if (!_settings) { if (!_settings) {
return; return;
} }
const hasChanges = Object.entries(update).some( // Admin-locked settings are enforced at write time too: a control that
// 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,
); );
@@ -519,7 +524,7 @@ export const useSettings = () => {
const newSettings = { const newSettings = {
...defaultValues, ...defaultValues,
..._settings, ..._settings,
...update, ...sanitizedUpdate,
} as Settings; } as Settings;
setSettings(newSettings); setSettings(newSettings);
saveSettings(newSettings); saveSettings(newSettings);
@@ -542,13 +547,24 @@ 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);
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey); // When unlocked, keep the user's value only if they explicitly diverged
// 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
: hasMeaningfulSettingValue(effectiveValue) : userDiverged
? effectiveValue ? userValue
: value; : hasMeaningfulSettingValue(value)
? value
: defaultValues[settingsKey];
} }
return acc; return acc;
}, {}); }, {});