mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-15 17:02:58 +01:00
Compare commits
8 Commits
fix/auth-s
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7bdd99cdd | ||
|
|
6ba1111794 | ||
|
|
9fea9015eb | ||
|
|
500da94583 | ||
|
|
fb8f11460b | ||
|
|
112d9bef96 | ||
|
|
692e02e42f | ||
|
|
8618e2d6c3 |
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
const hasStreamystats = !!pluginSettings?.streamyStatsServerUrl?.value;
|
||||||
|
|
||||||
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 +41,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 +53,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 +65,15 @@ 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}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
@@ -92,32 +81,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 +108,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
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 () => {
|
||||||
|
await refreshStreamyfinPluginSettings();
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
||||||
|
}, [refreshStreamyfinPluginSettings, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -18,6 +30,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { Image } from "expo-image";
|
|||||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
|
|
||||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
@@ -548,7 +547,6 @@ function Layout() {
|
|||||||
closeButton
|
closeButton
|
||||||
/>
|
/>
|
||||||
{!Platform.isTV && <GlobalModal />}
|
{!Platform.isTV && <GlobalModal />}
|
||||||
{!Platform.isTV && <PendingAccountSaveModal />}
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntroSheetProvider>
|
</IntroSheetProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import type React from "react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
|
||||||
import {
|
|
||||||
pendingAccountSaveAtom,
|
|
||||||
useJellyfin,
|
|
||||||
userAtom,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Post-login save-account prompt. Login flows (password or Quick Connect)
|
|
||||||
* only flag the intent via pendingAccountSaveAtom; the protection picker
|
|
||||||
* shows here, AFTER the session is authorized — the login screen itself
|
|
||||||
* unmounts as soon as the user is set, so it can't host the modal.
|
|
||||||
*/
|
|
||||||
export const PendingAccountSaveModal: React.FC = () => {
|
|
||||||
const [pending, setPending] = useAtom(pendingAccountSaveAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const { saveCurrentAccount } = useJellyfin();
|
|
||||||
|
|
||||||
// A logout before answering drops the intent — it must not resurface on
|
|
||||||
// the next (possibly different) login.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user && pending) setPending(null);
|
|
||||||
}, [user, pending, setPending]);
|
|
||||||
|
|
||||||
if (Platform.isTV) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SaveAccountModal
|
|
||||||
visible={!!pending && !!user}
|
|
||||||
username={user?.Name ?? ""}
|
|
||||||
onClose={() => setPending(null)}
|
|
||||||
onSave={(securityType, pinCode) => {
|
|
||||||
const serverName = pending?.serverName;
|
|
||||||
setPending(null);
|
|
||||||
saveCurrentAccount({ securityType, pinCode, serverName }).catch(
|
|
||||||
(error) => console.warn("Failed to save account:", error),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -39,11 +39,7 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
|||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||||
import {
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
apiAtom,
|
|
||||||
pendingAccountSaveAtom,
|
|
||||||
userAtom,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
@@ -93,9 +89,6 @@ const HomeMobile = () => {
|
|||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||||
const { showIntro } = useIntroSheet();
|
const { showIntro } = useIntroSheet();
|
||||||
// Gate the intro so it can't steal presentation from the post-login
|
|
||||||
// save-account sheet (both are BottomSheetModals): wait until no save is pending.
|
|
||||||
const pendingAccountSave = useAtomValue(pendingAccountSaveAtom);
|
|
||||||
|
|
||||||
// Fallback refresh for newly added content when returning to the home screen
|
// Fallback refresh for newly added content when returning to the home screen
|
||||||
// (primary path is the LibraryChanged WebSocket event).
|
// (primary path is the LibraryChanged WebSocket event).
|
||||||
@@ -104,9 +97,7 @@ const HomeMobile = () => {
|
|||||||
// Show intro modal on first launch
|
// Show intro modal on first launch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||||
// Defer while the save-account sheet is up; this effect re-runs and schedules
|
if (!hasShownIntro) {
|
||||||
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
|
|
||||||
if (!hasShownIntro && !pendingAccountSave) {
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
showIntro();
|
showIntro();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -115,7 +106,7 @@ const HomeMobile = () => {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [showIntro, pendingAccountSave]);
|
}, [showIntro]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -21,14 +21,13 @@ import { Input } from "@/components/common/Input";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
|
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import {
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
apiAtom,
|
import type {
|
||||||
pendingAccountSaveAtom,
|
AccountSecurityType,
|
||||||
useJellyfin,
|
SavedServer,
|
||||||
userAtom,
|
} from "@/utils/secureCredentials";
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import type { SavedServer } from "@/utils/secureCredentials";
|
|
||||||
|
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
@@ -36,7 +35,6 @@ const CredentialsSchema = z.object({
|
|||||||
|
|
||||||
export const Login: React.FC = () => {
|
export const Login: React.FC = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const {
|
const {
|
||||||
@@ -47,7 +45,6 @@ export const Login: React.FC = () => {
|
|||||||
loginWithSavedCredential,
|
loginWithSavedCredential,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
} = useJellyfin();
|
} = useJellyfin();
|
||||||
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -67,24 +64,13 @@ export const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save account state — only the intent lives here; the protection picker is
|
// Save account state
|
||||||
// the global PendingAccountSaveModal, shown after the login succeeds.
|
|
||||||
const [saveAccount, setSaveAccount] = useState(false);
|
const [saveAccount, setSaveAccount] = useState(false);
|
||||||
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
// Tracks an in-flight Quick Connect attempt (code issued, provider polling).
|
const [pendingLogin, setPendingLogin] = useState<{
|
||||||
const [quickConnectActive, setQuickConnectActive] = useState(false);
|
username: string;
|
||||||
|
password: string;
|
||||||
// A Quick Connect login with "save account" on flags the post-login save:
|
} | null>(null);
|
||||||
// the protection picker shows globally once the session exists (this screen
|
|
||||||
// unmounts on login, so it can't host the modal).
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
if (quickConnectActive && saveAccount) {
|
|
||||||
setPendingAccountSave({ serverName });
|
|
||||||
}
|
|
||||||
setQuickConnectActive(false);
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
// Handle URL params for server connection
|
// Handle URL params for server connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -131,22 +117,29 @@ export const Login: React.FC = () => {
|
|||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
if (!result.success) return;
|
if (!result.success) return;
|
||||||
|
|
||||||
const ok = await performLogin(credentials.username, credentials.password);
|
if (saveAccount) {
|
||||||
// The protection picker shows AFTER a successful login (global modal) —
|
setPendingLogin({
|
||||||
// never for a failed one.
|
username: credentials.username,
|
||||||
if (ok && saveAccount) {
|
password: credentials.password,
|
||||||
setPendingAccountSave({ serverName });
|
});
|
||||||
|
setShowSaveModal(true);
|
||||||
|
} else {
|
||||||
|
await performLogin(credentials.username, credentials.password);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const performLogin = async (
|
const performLogin = async (
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<boolean> => {
|
options?: {
|
||||||
|
saveAccount?: boolean;
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(username, password, serverName);
|
await login(username, password, serverName, options);
|
||||||
return true;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
@@ -156,9 +149,23 @@ export const Login: React.FC = () => {
|
|||||||
t("login.an_unexpected_error_occured"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setPendingLogin(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAccountConfirm = async (
|
||||||
|
securityType: AccountSecurityType,
|
||||||
|
pinCode?: string,
|
||||||
|
) => {
|
||||||
|
setShowSaveModal(false);
|
||||||
|
if (pendingLogin) {
|
||||||
|
await performLogin(pendingLogin.username, pendingLogin.password, {
|
||||||
|
saveAccount: true,
|
||||||
|
securityType,
|
||||||
|
pinCode,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -252,7 +259,6 @@ export const Login: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
setQuickConnectActive(true);
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.quick_connect"),
|
t("login.quick_connect"),
|
||||||
t("login.enter_code_to_login", { code: code }),
|
t("login.enter_code_to_login", { code: code }),
|
||||||
@@ -437,6 +443,16 @@ export const Login: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
<SaveAccountModal
|
||||||
|
visible={showSaveModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSaveModal(false);
|
||||||
|
setPendingLogin(null);
|
||||||
|
}}
|
||||||
|
onSave={handleSaveAccountConfirm}
|
||||||
|
username={pendingLogin?.username || credentials.username}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -115,6 +118,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 +132,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 +145,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'>
|
||||||
|
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")}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -92,12 +91,6 @@ export const apiAtom = atom<Api | null>(initialApi);
|
|||||||
export const userAtom = atom<UserDto | null>(initialUser);
|
export const userAtom = atom<UserDto | null>(initialUser);
|
||||||
export const wsAtom = atom<WebSocket | null>(null);
|
export const wsAtom = atom<WebSocket | null>(null);
|
||||||
export const cacheVersionAtom = atom<number>(0);
|
export const cacheVersionAtom = atom<number>(0);
|
||||||
// Set by a login flow that wants the account saved: the protection picker
|
|
||||||
// shows AFTER the session is authorized (the login screen unmounts on
|
|
||||||
// success, so the modal lives at the root — see PendingAccountSaveModal).
|
|
||||||
export const pendingAccountSaveAtom = atom<{ serverName?: string } | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
interface LoginOptions {
|
interface LoginOptions {
|
||||||
saveAccount?: boolean;
|
saveAccount?: boolean;
|
||||||
@@ -115,11 +108,6 @@ interface JellyfinContextValue {
|
|||||||
serverName?: string,
|
serverName?: string,
|
||||||
options?: LoginOptions,
|
options?: LoginOptions,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
saveCurrentAccount: (options?: {
|
|
||||||
securityType?: AccountSecurityType;
|
|
||||||
pinCode?: string;
|
|
||||||
serverName?: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
initiateQuickConnect: () => Promise<string | undefined>;
|
initiateQuickConnect: () => Promise<string | undefined>;
|
||||||
stopQuickConnectPolling: () => void;
|
stopQuickConnectPolling: () => void;
|
||||||
@@ -177,46 +165,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// --- Session-expiry handling ----------------------------------------------
|
|
||||||
// When the server revokes the token (e.g. the device/session is deleted), a
|
|
||||||
// 401 can surface from any authenticated request. Without central handling
|
|
||||||
// the dead token stays in storage, so every reload re-fires authed calls →
|
|
||||||
// 401 spam + uncaught rejections, and the app lingers in a half-authenticated
|
|
||||||
// state. A single response interceptor on the authenticated api clears the
|
|
||||||
// session on the first 401 so the app drops cleanly to the login screen.
|
|
||||||
const sessionExpiredRef = useRef(false);
|
|
||||||
|
|
||||||
const handleSessionExpired = useCallback(() => {
|
|
||||||
if (sessionExpiredRef.current) return; // run once per session
|
|
||||||
sessionExpiredRef.current = true;
|
|
||||||
storage.remove("token");
|
|
||||||
storage.remove("user");
|
|
||||||
setUser(null);
|
|
||||||
setApi(null);
|
|
||||||
queryClient.clear();
|
|
||||||
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
|
||||||
// Saved credentials are kept so the user can quick-login again.
|
|
||||||
}, [setUser, setApi, queryClient]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Only guard an authenticated session. A pre-auth api (login screen) keeps
|
|
||||||
// its own handling — a wrong-password 401 is not a session expiry.
|
|
||||||
if (!api?.accessToken) return;
|
|
||||||
sessionExpiredRef.current = false; // re-arm for this fresh session
|
|
||||||
const interceptorId = api.axiosInstance.interceptors.response.use(
|
|
||||||
(response) => response,
|
|
||||||
(error) => {
|
|
||||||
if (error?.response?.status === 401) {
|
|
||||||
handleSessionExpired();
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return () => {
|
|
||||||
api.axiosInstance.interceptors.response.eject(interceptorId);
|
|
||||||
};
|
|
||||||
}, [api, handleSessionExpired]);
|
|
||||||
|
|
||||||
const headers = useMemo(() => {
|
const headers = useMemo(() => {
|
||||||
if (!deviceId) return {};
|
if (!deviceId) return {};
|
||||||
return {
|
return {
|
||||||
@@ -359,37 +307,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Persist the CURRENT session to secure storage — used by the post-login
|
|
||||||
// save-account modal (the protection picker shows AFTER a successful
|
|
||||||
// login, for both the password and Quick Connect flows).
|
|
||||||
const saveCurrentAccount = useCallback(
|
|
||||||
async (options?: {
|
|
||||||
securityType?: AccountSecurityType;
|
|
||||||
pinCode?: string;
|
|
||||||
serverName?: string;
|
|
||||||
}) => {
|
|
||||||
const token = storage.getString("token");
|
|
||||||
if (!api?.basePath || !user?.Id || !user.Name || !token) return;
|
|
||||||
const securityType = options?.securityType || "none";
|
|
||||||
let pinHash: string | undefined;
|
|
||||||
if (securityType === "pin" && options?.pinCode) {
|
|
||||||
pinHash = await hashPIN(options.pinCode);
|
|
||||||
}
|
|
||||||
await saveAccountCredential({
|
|
||||||
serverUrl: api.basePath,
|
|
||||||
serverName: options?.serverName || "",
|
|
||||||
token,
|
|
||||||
userId: user.Id,
|
|
||||||
username: user.Name,
|
|
||||||
savedAt: Date.now(),
|
|
||||||
securityType,
|
|
||||||
pinHash,
|
|
||||||
primaryImageTag: user.PrimaryImageTag ?? undefined,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[api?.basePath, user],
|
|
||||||
);
|
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const loginMutation = useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
username,
|
username,
|
||||||
@@ -592,9 +509,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// Expected, handled case (e.g. revoked token → "Session Expired", or
|
console.error("Quick login failed:", error);
|
||||||
// server unreachable): the UI surfaces the message, so warn, don't error.
|
|
||||||
console.warn("Quick login failed:", error);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -705,14 +620,12 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setUser(storedUser);
|
setUser(storedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the token and refresh user data in the background. Do NOT
|
// Dismiss splash screen with cached data immediately,
|
||||||
// await this: the Jellyfin SDK axios instance has no timeout, so when
|
// fetch fresh user data in the background
|
||||||
// offline this call hangs for the full OS TCP timeout (75-120s) and
|
setInitialLoaded(true);
|
||||||
// blocks splash dismissal. The cached storedUser (set above) is enough
|
|
||||||
// to render; on success we just refresh it.
|
try {
|
||||||
getUserApi(apiInstance)
|
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||||
.getCurrentUser()
|
|
||||||
.then(async (response) => {
|
|
||||||
setUser(response.data);
|
setUser(response.data);
|
||||||
|
|
||||||
// Migrate current session to secure storage if not already saved
|
// Migrate current session to secure storage if not already saved
|
||||||
@@ -746,21 +659,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
} catch (e) {
|
||||||
.catch((e) => {
|
// Background fetch failed — app already rendered with cached data
|
||||||
// Expected, handled case (offline, or a token the server rejects —
|
console.warn("Background user fetch failed, using cached data:", e);
|
||||||
// the UI prompts re-login): warn, don't error. Log only
|
}
|
||||||
// status/message — never the raw error (axios errors carry the
|
} else {
|
||||||
// request config incl. the Authorization header / token).
|
setInitialLoaded(true);
|
||||||
console.warn(
|
|
||||||
"Background user validation failed:",
|
|
||||||
e?.response?.status ?? e?.message ?? "unknown error",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
|
||||||
setInitialLoaded(true);
|
setInitialLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -774,7 +681,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
removeServer: () => removeServerMutation.mutateAsync(),
|
removeServer: () => removeServerMutation.mutateAsync(),
|
||||||
login: (username, password, serverName, options) =>
|
login: (username, password, serverName, options) =>
|
||||||
loginMutation.mutateAsync({ username, password, serverName, options }),
|
loginMutation.mutateAsync({ username, password, serverName, options }),
|
||||||
saveCurrentAccount,
|
|
||||||
logout: () => logoutMutation.mutateAsync(),
|
logout: () => logoutMutation.mutateAsync(),
|
||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
stopQuickConnectPolling,
|
stopQuickConnectPolling,
|
||||||
|
|||||||
@@ -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;
|
||||||
}, {});
|
}, {});
|
||||||
|
|||||||
Reference in New Issue
Block a user