mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-15 17:02:58 +01:00
Compare commits
5 Commits
fix/plugin
...
fix/auth-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ff3847d80 | ||
|
|
8cf141e03b | ||
|
|
34f01e2c13 | ||
|
|
98dfefbdd0 | ||
|
|
49a903f48a |
@@ -1,8 +1,11 @@
|
|||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function JellyseerrPluginPage() {
|
export default function JellyseerrPluginPage() {
|
||||||
|
const { pluginSettings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -13,9 +16,12 @@ export default function JellyseerrPluginPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='p-4'>
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||||
|
className='p-4'
|
||||||
|
>
|
||||||
<JellyseerrSettings />
|
<JellyseerrSettings />
|
||||||
</View>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function KefinTweaksPage() {
|
export default function KefinTweaksPage() {
|
||||||
|
const { pluginSettings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -13,9 +16,12 @@ export default function KefinTweaksPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='px-4'>
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
||||||
|
className='p-4'
|
||||||
|
>
|
||||||
<KefinTweaksSettings />
|
<KefinTweaksSettings />
|
||||||
</View>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
@@ -14,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,8 +41,15 @@ export default function MarlinSearchPage() {
|
|||||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const disabled = useMemo(() => {
|
||||||
|
return (
|
||||||
|
pluginSettings?.searchEngine?.locked === true &&
|
||||||
|
pluginSettings?.marlinServerUrl?.locked === true
|
||||||
|
);
|
||||||
|
}, [pluginSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!marlinUrlLocked) {
|
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
||||||
@@ -53,7 +60,7 @@ export default function MarlinSearchPage() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [navigation, value, marlinUrlLocked, t]);
|
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -65,15 +72,19 @@ export default function MarlinSearchPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='px-4'>
|
<DisabledSetting disabled={disabled} className='px-4'>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
{/* disabledByAdmin renders the "Disabled by admin" notice as the row's
|
<DisabledSetting
|
||||||
subtitle (same pattern as the Streamystats settings) — no clipping. */}
|
disabled={
|
||||||
|
pluginSettings?.searchEngine?.locked === true ||
|
||||||
|
!!pluginSettings?.streamyStatsServerUrl?.value
|
||||||
|
}
|
||||||
|
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||||
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={searchEngineLocked}
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
@@ -81,23 +92,32 @@ export default function MarlinSearchPage() {
|
|||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.searchEngine === "Marlin"}
|
value={settings.searchEngine === "Marlin"}
|
||||||
disabled={searchEngineLocked || hasStreamystats}
|
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
||||||
onValueChange={(val) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
|
updateSettings({
|
||||||
|
searchEngine: value ? "Marlin" : "Jellyfin",
|
||||||
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
</DisabledSetting>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<ListGroup className='mt-2'>
|
<DisabledSetting
|
||||||
<ListItem
|
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||||
title={t("home.settings.plugins.marlin_search.url")}
|
showText={!pluginSettings?.searchEngine?.locked}
|
||||||
disabledByAdmin={marlinUrlLocked}
|
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||||
>
|
>
|
||||||
|
<View
|
||||||
|
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
|
||||||
|
>
|
||||||
|
<Text className='mr-4'>
|
||||||
|
{t("home.settings.plugins.marlin_search.url")}
|
||||||
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
|
editable={settings.searchEngine === "Marlin"}
|
||||||
className='text-white text-right flex-1'
|
className='text-white'
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
@@ -108,16 +128,15 @@ export default function MarlinSearchPage() {
|
|||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
onChangeText={(text) => setValue(text)}
|
onChangeText={(text) => setValue(text)}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</View>
|
||||||
</ListGroup>
|
</DisabledSetting>
|
||||||
|
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
import { useCallback } from "react";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function PluginsPage() {
|
export default function PluginsPage() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { t } = useTranslation();
|
|
||||||
const { refreshStreamyfinPluginSettings } = useSettings();
|
|
||||||
|
|
||||||
const handleRefreshFromServer = useCallback(async () => {
|
|
||||||
await refreshStreamyfinPluginSettings();
|
|
||||||
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
|
||||||
}, [refreshStreamyfinPluginSettings, t]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -30,17 +18,6 @@ export default function PluginsPage() {
|
|||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
{/* Pulls the centralised Streamyfin plugin settings for every plugin,
|
|
||||||
so it lives on the plugins index rather than inside Streamystats. */}
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleRefreshFromServer}
|
|
||||||
className='py-3 rounded-xl bg-neutral-800'
|
|
||||||
>
|
|
||||||
<Text className='text-center text-blue-500'>
|
|
||||||
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ export default function StreamystatsPage() {
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const {
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
pluginSettings,
|
||||||
|
refreshStreamyfinPluginSettings,
|
||||||
|
} = useSettings();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
// Local state for all editable fields
|
// Local state for all editable fields
|
||||||
@@ -44,21 +49,7 @@ export default function StreamystatsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||||
const searchLocked = pluginSettings?.searchEngine?.locked === true;
|
const isStreamystatsEnabled = !!url;
|
||||||
const movieRecsLocked =
|
|
||||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true;
|
|
||||||
const seriesRecsLocked =
|
|
||||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true;
|
|
||||||
const promotedWatchlistsLocked =
|
|
||||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true;
|
|
||||||
const hideWatchlistsTabLocked =
|
|
||||||
pluginSettings?.hideWatchlistsTab?.locked === true;
|
|
||||||
// The input renders the locked admin URL; enablement must follow the same
|
|
||||||
// effective value or every toggle stays disabled until local state syncs.
|
|
||||||
const effectiveUrl = isUrlLocked
|
|
||||||
? (settings?.streamyStatsServerUrl ?? "")
|
|
||||||
: url;
|
|
||||||
const isStreamystatsEnabled = !!effectiveUrl;
|
|
||||||
|
|
||||||
const onSave = useCallback(() => {
|
const onSave = useCallback(() => {
|
||||||
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||||
@@ -122,6 +113,17 @@ export default function StreamystatsPage() {
|
|||||||
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
|
const newPluginSettings = await refreshStreamyfinPluginSettings();
|
||||||
|
// Update local state with new values
|
||||||
|
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||||
|
setUrl(newUrl);
|
||||||
|
if (newUrl) {
|
||||||
|
setUseForSearch(true);
|
||||||
|
}
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
||||||
|
}, [refreshStreamyfinPluginSettings, t]);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -144,7 +146,7 @@ export default function StreamystatsPage() {
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
value={effectiveUrl}
|
value={url}
|
||||||
keyboardType='url'
|
keyboardType='url'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
@@ -169,18 +171,11 @@ export default function StreamystatsPage() {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||||
disabledByAdmin={searchLocked}
|
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
||||||
>
|
>
|
||||||
{/* Locked controls show the live admin value and can't be toggled —
|
|
||||||
local form state would let the switch flip while the write guard
|
|
||||||
drops the change. */}
|
|
||||||
<Switch
|
<Switch
|
||||||
value={
|
value={useForSearch}
|
||||||
searchLocked
|
disabled={!isStreamystatsEnabled}
|
||||||
? settings?.searchEngine === "Streamystats"
|
|
||||||
: useForSearch
|
|
||||||
}
|
|
||||||
disabled={!isStreamystatsEnabled || searchLocked}
|
|
||||||
onValueChange={setUseForSearch}
|
onValueChange={setUseForSearch}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -188,62 +183,52 @@ export default function StreamystatsPage() {
|
|||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={movieRecsLocked}
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={
|
value={movieRecs}
|
||||||
movieRecsLocked
|
|
||||||
? (settings?.streamyStatsMovieRecommendations ?? false)
|
|
||||||
: movieRecs
|
|
||||||
}
|
|
||||||
onValueChange={setMovieRecs}
|
onValueChange={setMovieRecs}
|
||||||
disabled={!isStreamystatsEnabled || movieRecsLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={seriesRecsLocked}
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={
|
value={seriesRecs}
|
||||||
seriesRecsLocked
|
|
||||||
? (settings?.streamyStatsSeriesRecommendations ?? false)
|
|
||||||
: seriesRecs
|
|
||||||
}
|
|
||||||
onValueChange={setSeriesRecs}
|
onValueChange={setSeriesRecs}
|
||||||
disabled={!isStreamystatsEnabled || seriesRecsLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={promotedWatchlistsLocked}
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={
|
value={promotedWatchlists}
|
||||||
promotedWatchlistsLocked
|
|
||||||
? (settings?.streamyStatsPromotedWatchlists ?? false)
|
|
||||||
: promotedWatchlists
|
|
||||||
}
|
|
||||||
onValueChange={setPromotedWatchlists}
|
onValueChange={setPromotedWatchlists}
|
||||||
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||||
disabledByAdmin={hideWatchlistsTabLocked}
|
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={
|
value={hideWatchlistsTab}
|
||||||
hideWatchlistsTabLocked
|
|
||||||
? (settings?.hideWatchlistsTab ?? false)
|
|
||||||
: hideWatchlistsTab
|
|
||||||
}
|
|
||||||
onValueChange={setHideWatchlistsTab}
|
onValueChange={setHideWatchlistsTab}
|
||||||
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
@@ -251,6 +236,15 @@ export default function StreamystatsPage() {
|
|||||||
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleRefreshFromServer}
|
||||||
|
className='mt-6 py-3 rounded-xl bg-neutral-800'
|
||||||
|
>
|
||||||
|
<Text className='text-center text-blue-500'>
|
||||||
|
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
||||||
{!isUrlLocked && isStreamystatsEnabled && (
|
{!isUrlLocked && isStreamystatsEnabled && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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";
|
||||||
@@ -547,6 +548,7 @@ function Layout() {
|
|||||||
closeButton
|
closeButton
|
||||||
/>
|
/>
|
||||||
{!Platform.isTV && <GlobalModal />}
|
{!Platform.isTV && <GlobalModal />}
|
||||||
|
{!Platform.isTV && <PendingAccountSaveModal />}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntroSheetProvider>
|
</IntroSheetProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
|
|||||||
45
components/PendingAccountSaveModal.tsx
Normal file
45
components/PendingAccountSaveModal.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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,7 +39,11 @@ 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 { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import {
|
||||||
|
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";
|
||||||
@@ -89,6 +93,9 @@ 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).
|
||||||
@@ -97,7 +104,9 @@ 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");
|
||||||
if (!hasShownIntro) {
|
// Defer while the save-account sheet is up; this effect re-runs and schedules
|
||||||
|
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
|
||||||
|
if (!hasShownIntro && !pendingAccountSave) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
showIntro();
|
showIntro();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -106,7 +115,7 @@ const HomeMobile = () => {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [showIntro]);
|
}, [showIntro, pendingAccountSave]);
|
||||||
|
|
||||||
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 } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -21,13 +21,14 @@ 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 { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import {
|
||||||
import type {
|
apiAtom,
|
||||||
AccountSecurityType,
|
pendingAccountSaveAtom,
|
||||||
SavedServer,
|
useJellyfin,
|
||||||
} from "@/utils/secureCredentials";
|
userAtom,
|
||||||
|
} 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")),
|
||||||
@@ -35,6 +36,7 @@ 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 {
|
||||||
@@ -45,6 +47,7 @@ export const Login: React.FC = () => {
|
|||||||
loginWithSavedCredential,
|
loginWithSavedCredential,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
} = useJellyfin();
|
} = useJellyfin();
|
||||||
|
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -64,13 +67,24 @@ export const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save account state
|
// Save account state — only the intent lives here; the protection picker is
|
||||||
|
// the global PendingAccountSaveModal, shown after the login succeeds.
|
||||||
const [saveAccount, setSaveAccount] = useState(false);
|
const [saveAccount, setSaveAccount] = useState(false);
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
|
||||||
const [pendingLogin, setPendingLogin] = useState<{
|
// Tracks an in-flight Quick Connect attempt (code issued, provider polling).
|
||||||
username: string;
|
const [quickConnectActive, setQuickConnectActive] = useState(false);
|
||||||
password: string;
|
|
||||||
} | null>(null);
|
// A Quick Connect login with "save account" on flags the post-login save:
|
||||||
|
// 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(() => {
|
||||||
@@ -117,29 +131,22 @@ export const Login: React.FC = () => {
|
|||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
if (!result.success) return;
|
if (!result.success) return;
|
||||||
|
|
||||||
if (saveAccount) {
|
const ok = await performLogin(credentials.username, credentials.password);
|
||||||
setPendingLogin({
|
// The protection picker shows AFTER a successful login (global modal) —
|
||||||
username: credentials.username,
|
// never for a failed one.
|
||||||
password: credentials.password,
|
if (ok && saveAccount) {
|
||||||
});
|
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,
|
||||||
options?: {
|
): Promise<boolean> => {
|
||||||
saveAccount?: boolean;
|
|
||||||
securityType?: AccountSecurityType;
|
|
||||||
pinCode?: string;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(username, password, serverName, options);
|
await login(username, password, serverName);
|
||||||
|
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);
|
||||||
@@ -149,23 +156,9 @@ 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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,6 +252,7 @@ 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 }),
|
||||||
@@ -443,16 +437,6 @@ 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,10 +20,7 @@ export const JellyseerrSettings = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
// Only the server URL is admin-lockable — the password stays editable so
|
|
||||||
// the user can still sign in to the admin-pinned Jellyseerr server.
|
|
||||||
const urlLocked = pluginSettings?.jellyseerrServerUrl?.locked === true;
|
|
||||||
|
|
||||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
@@ -118,7 +115,6 @@ export const JellyseerrSettings = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||||
<View style={{ opacity: urlLocked ? 0.5 : 1 }}>
|
|
||||||
<Text className='font-bold mb-1'>
|
<Text className='font-bold mb-1'>
|
||||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -132,11 +128,7 @@ export const JellyseerrSettings = () => {
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
value={
|
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
||||||
urlLocked
|
|
||||||
? settings?.jellyseerrServerUrl
|
|
||||||
: (jellyseerrServerUrl ?? settings?.jellyseerrServerUrl)
|
|
||||||
}
|
|
||||||
defaultValue={
|
defaultValue={
|
||||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||||
}
|
}
|
||||||
@@ -145,14 +137,8 @@ export const JellyseerrSettings = () => {
|
|||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
onChangeText={setjellyseerrServerUrl}
|
onChangeText={setjellyseerrServerUrl}
|
||||||
editable={!urlLocked && !loginToJellyseerrMutation.isPending}
|
editable={!loginToJellyseerrMutation.isPending}
|
||||||
/>
|
/>
|
||||||
{urlLocked && (
|
|
||||||
<Text className='text-xs text-red-600 mb-2'>
|
|
||||||
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,28 +1,33 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch } from "react-native";
|
import { Switch, Text, View } from "react-native";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
export const KefinTweaksSettings = () => {
|
export const KefinTweaksSettings = () => {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const isEnabled = settings?.useKefinTweaks ?? false;
|
const isEnabled = settings?.useKefinTweaks ?? false;
|
||||||
const locked = pluginSettings?.useKefinTweaks?.locked === true;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListGroup>
|
<View className=''>
|
||||||
<ListItem
|
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||||
title={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
<Text className='text-xs text-red-600 mb-2'>
|
||||||
disabledByAdmin={locked}
|
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
||||||
>
|
</Text>
|
||||||
|
|
||||||
|
<View className='flex flex-row items-center justify-between mt-2'>
|
||||||
|
<Text className='text-white'>
|
||||||
|
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
value={isEnabled}
|
value={isEnabled}
|
||||||
disabled={locked}
|
|
||||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||||
|
trackColor={{ false: "#555", true: "purple" }}
|
||||||
|
thumbColor={isEnabled ? "#fff" : "#ccc"}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</View>
|
||||||
</ListGroup>
|
</View>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,12 +20,7 @@ export const PluginSettings = () => {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
||||||
title='Jellyseerr'
|
title={"Jellyseerr"}
|
||||||
showArrow
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
|
||||||
title='Streamystats'
|
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
|
|||||||
title='Marlin Search'
|
title='Marlin Search'
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
||||||
|
title='Streamystats'
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||||
title='KefinTweaks'
|
title='KefinTweaks'
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -91,6 +92,12 @@ 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;
|
||||||
@@ -108,6 +115,11 @@ 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;
|
||||||
@@ -165,6 +177,46 @@ 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 {
|
||||||
@@ -307,6 +359,37 @@ 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,
|
||||||
@@ -509,7 +592,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Quick login failed:", error);
|
// Expected, handled case (e.g. revoked token → "Session Expired", or
|
||||||
|
// server unreachable): the UI surfaces the message, so warn, don't error.
|
||||||
|
console.warn("Quick login failed:", error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -620,12 +705,14 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setUser(storedUser);
|
setUser(storedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dismiss splash screen with cached data immediately,
|
// Validate the token and refresh user data in the background. Do NOT
|
||||||
// fetch fresh user data in the background
|
// await this: the Jellyfin SDK axios instance has no timeout, so when
|
||||||
setInitialLoaded(true);
|
// offline this call hangs for the full OS TCP timeout (75-120s) and
|
||||||
|
// blocks splash dismissal. The cached storedUser (set above) is enough
|
||||||
try {
|
// to render; on success we just refresh it.
|
||||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
getUserApi(apiInstance)
|
||||||
|
.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
|
||||||
@@ -659,15 +746,21 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
})
|
||||||
// Background fetch failed — app already rendered with cached data
|
.catch((e) => {
|
||||||
console.warn("Background user fetch failed, using cached data:", e);
|
// Expected, handled case (offline, or a token the server rejects —
|
||||||
}
|
// the UI prompts re-login): warn, don't error. Log only
|
||||||
} else {
|
// status/message — never the raw error (axios errors carry the
|
||||||
setInitialLoaded(true);
|
// request config incl. the Authorization header / token).
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -681,6 +774,7 @@ 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,6 +449,11 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
|
|||||||
const hasMeaningfulSettingValue = (value: unknown) =>
|
const hasMeaningfulSettingValue = (value: unknown) =>
|
||||||
value !== undefined && value !== null && value !== "";
|
value !== undefined && value !== null && value !== "";
|
||||||
|
|
||||||
|
const getEffectiveSettingValue = <K extends keyof Settings>(
|
||||||
|
settings: Partial<Settings> | null | undefined,
|
||||||
|
settingsKey: K,
|
||||||
|
) => settings?.[settingsKey] ?? defaultValues[settingsKey];
|
||||||
|
|
||||||
export const useSettings = () => {
|
export const useSettings = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const [_settings, setSettings] = useAtom(settingsAtom);
|
const [_settings, setSettings] = useAtom(settingsAtom);
|
||||||
@@ -505,17 +510,7 @@ export const useSettings = () => {
|
|||||||
if (!_settings) {
|
if (!_settings) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Admin-locked settings are enforced at write time too: a control that
|
const hasChanges = Object.entries(update).some(
|
||||||
// isn't disabled in the UI must not persist a value the admin pinned.
|
|
||||||
// The read memo already overrides locked keys, but without this guard the
|
|
||||||
// write would silently land in user storage and resurface once unlocked.
|
|
||||||
const sanitizedUpdate = Object.fromEntries(
|
|
||||||
Object.entries(update).filter(
|
|
||||||
([key]) => pluginSettings?.[key as keyof Settings]?.locked !== true,
|
|
||||||
),
|
|
||||||
) as Partial<Settings>;
|
|
||||||
|
|
||||||
const hasChanges = Object.entries(sanitizedUpdate).some(
|
|
||||||
([key, value]) => _settings[key as keyof Settings] !== value,
|
([key, value]) => _settings[key as keyof Settings] !== value,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -524,7 +519,7 @@ export const useSettings = () => {
|
|||||||
const newSettings = {
|
const newSettings = {
|
||||||
...defaultValues,
|
...defaultValues,
|
||||||
..._settings,
|
..._settings,
|
||||||
...sanitizedUpdate,
|
...update,
|
||||||
} as Settings;
|
} as Settings;
|
||||||
setSettings(newSettings);
|
setSettings(newSettings);
|
||||||
saveSettings(newSettings);
|
saveSettings(newSettings);
|
||||||
@@ -547,24 +542,13 @@ export const useSettings = () => {
|
|||||||
// Normalize object-typed settings from plugin (plain primitive → { key, value })
|
// Normalize object-typed settings from plugin (plain primitive → { key, value })
|
||||||
value = normalizePluginValue(settingsKey, value);
|
value = normalizePluginValue(settingsKey, value);
|
||||||
|
|
||||||
// When unlocked, keep the user's value only if they explicitly diverged
|
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
|
||||||
// from the app default. Otherwise the plugin value is the admin's
|
|
||||||
// default and must win over the hardcoded app default — e.g. a toggle
|
|
||||||
// that was always locked then unlocked should reflect the plugin
|
|
||||||
// default, not the app's `false`. Object-typed settings compare by
|
|
||||||
// reference, so their behaviour is unchanged.
|
|
||||||
const userValue = _settings?.[settingsKey];
|
|
||||||
const userDiverged =
|
|
||||||
hasMeaningfulSettingValue(userValue) &&
|
|
||||||
userValue !== defaultValues[settingsKey];
|
|
||||||
|
|
||||||
(acc as any)[settingsKey] = locked
|
(acc as any)[settingsKey] = locked
|
||||||
? value
|
? value
|
||||||
: userDiverged
|
: hasMeaningfulSettingValue(effectiveValue)
|
||||||
? userValue
|
? effectiveValue
|
||||||
: hasMeaningfulSettingValue(value)
|
: value;
|
||||||
? value
|
|
||||||
: defaultValues[settingsKey];
|
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|||||||
Reference in New Issue
Block a user