mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-18 18:24:18 +01:00
Compare commits
9 Commits
feat/quick
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd4215407d | ||
|
|
b7bdd99cdd | ||
|
|
6ba1111794 | ||
|
|
9fea9015eb | ||
|
|
500da94583 | ||
|
|
fb8f11460b | ||
|
|
112d9bef96 | ||
|
|
692e02e42f | ||
|
|
8618e2d6c3 |
@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</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.other.select_libraries_you_want_to_hide")}
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</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.other.select_libraries_you_want_to_hide")}
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,24 @@ import { toast } from "sonner-native";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function MarlinSearchPage() {
|
export default function MarlinSearchPage() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
|
const searchEngineLocked = pluginSettings?.searchEngine?.locked === true;
|
||||||
|
const marlinUrlLocked = pluginSettings?.marlinServerUrl?.locked === true;
|
||||||
|
// Effective (user/admin merged) URL, same source the search screen uses —
|
||||||
|
// the raw plugin value misses a user-configured Streamystats.
|
||||||
|
const hasStreamystats = !!settings?.streamyStatsServerUrl;
|
||||||
|
|
||||||
const onSave = (val: string) => {
|
const onSave = (val: string) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||||
@@ -41,15 +43,8 @@ export default function MarlinSearchPage() {
|
|||||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
};
|
};
|
||||||
|
|
||||||
const disabled = useMemo(() => {
|
|
||||||
return (
|
|
||||||
pluginSettings?.searchEngine?.locked === true &&
|
|
||||||
pluginSettings?.marlinServerUrl?.locked === true
|
|
||||||
);
|
|
||||||
}, [pluginSettings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
if (!marlinUrlLocked) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
||||||
@@ -60,7 +55,7 @@ export default function MarlinSearchPage() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
|
}, [navigation, value, marlinUrlLocked, t]);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -72,52 +67,42 @@ 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 ||
|
<ListItem
|
||||||
!!pluginSettings?.streamyStatsServerUrl?.value
|
title={t(
|
||||||
}
|
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
)}
|
||||||
|
disabledByAdmin={searchEngineLocked}
|
||||||
|
// Streamystats owns the search engine while configured — block the
|
||||||
|
// row tap too, not just the Switch, so it can't force "Jellyfin".
|
||||||
|
disabled={hasStreamystats}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ListItem
|
<Switch
|
||||||
title={t(
|
value={settings.searchEngine === "Marlin"}
|
||||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
disabled={searchEngineLocked || hasStreamystats}
|
||||||
)}
|
onValueChange={(val) => {
|
||||||
onPress={() => {
|
updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Switch
|
</ListItem>
|
||||||
value={settings.searchEngine === "Marlin"}
|
|
||||||
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
updateSettings({
|
|
||||||
searchEngine: value ? "Marlin" : "Jellyfin",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</DisabledSetting>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<DisabledSetting
|
<ListGroup className='mt-2'>
|
||||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
<ListItem
|
||||||
showText={!pluginSettings?.searchEngine?.locked}
|
title={t("home.settings.plugins.marlin_search.url")}
|
||||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
disabledByAdmin={marlinUrlLocked}
|
||||||
>
|
|
||||||
<View
|
|
||||||
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
|
|
||||||
>
|
>
|
||||||
<Text className='mr-4'>
|
|
||||||
{t("home.settings.plugins.marlin_search.url")}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
editable={settings.searchEngine === "Marlin"}
|
editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
|
||||||
className='text-white'
|
className='text-white text-right flex-1'
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
@@ -128,15 +113,16 @@ export default function MarlinSearchPage() {
|
|||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
onChangeText={(text) => setValue(text)}
|
onChangeText={(text) => setValue(text)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</ListItem>
|
||||||
</DisabledSetting>
|
</ListGroup>
|
||||||
|
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
import { Platform, ScrollView, View } from "react-native";
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function PluginsPage() {
|
export default function PluginsPage() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { refreshStreamyfinPluginSettings } = useSettings();
|
||||||
|
|
||||||
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
|
// Returns undefined when the API call fails (handled internally).
|
||||||
|
const refreshed = await refreshStreamyfinPluginSettings();
|
||||||
|
if (refreshed) {
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
t("home.settings.plugins.streamystats.toasts.refresh_failed"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [refreshStreamyfinPluginSettings, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -18,6 +37,17 @@ export default function PluginsPage() {
|
|||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
|
{/* Pulls the centralised Streamyfin plugin settings for every plugin,
|
||||||
|
so it lives on the plugins index rather than inside Streamystats. */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleRefreshFromServer}
|
||||||
|
className='py-3 rounded-xl bg-neutral-800'
|
||||||
|
>
|
||||||
|
<Text className='text-center text-blue-500'>
|
||||||
|
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1318,7 +1318,7 @@ export default function DirectPlayerPage() {
|
|||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
t("player.error"),
|
||||||
t("player.an_error_occurred_while_playing_the_video"),
|
t("player.an_error_occured_while_playing_the_video"),
|
||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -85,8 +85,7 @@ configureReanimatedLogger({
|
|||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowBanner: true,
|
shouldShowAlert: true,
|
||||||
shouldShowList: true,
|
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
@@ -351,12 +350,9 @@ function Layout() {
|
|||||||
notificationListener.current =
|
notificationListener.current =
|
||||||
Notifications?.addNotificationReceivedListener(
|
Notifications?.addNotificationReceivedListener(
|
||||||
(notification: Notification) => {
|
(notification: Notification) => {
|
||||||
// Log only the title — serializing the whole notification touches
|
|
||||||
// the deprecated dataString getter (deprecation warning) and dumps
|
|
||||||
// noisy payloads into the console.
|
|
||||||
console.log(
|
console.log(
|
||||||
"Notification received while app running:",
|
"Notification received while app running",
|
||||||
notification.request.content.title,
|
notification,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -31,7 +31,6 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.18",
|
"expo-build-properties": "~56.0.18",
|
||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
"expo-clipboard": "~56.0.4",
|
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
@@ -1002,8 +1001,6 @@
|
|||||||
|
|
||||||
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
|
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
|
||||||
|
|
||||||
"expo-clipboard": ["expo-clipboard@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA=="],
|
|
||||||
|
|
||||||
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
|
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
|
||||||
|
|
||||||
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
||||||
|
|||||||
@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
|
|
||||||
// instead of freezing the first-rendered series' image (empty deps bug).
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
const seriesId = items[0]?.SeriesId;
|
return storage.getString(items[0].SeriesId!);
|
||||||
return seriesId ? storage.getString(seriesId) : undefined;
|
}, []);
|
||||||
}, [items[0]?.SeriesId]);
|
|
||||||
|
|
||||||
const deleteSeries = useCallback(
|
const deleteSeries = useCallback(
|
||||||
async () =>
|
async () =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { PropsWithChildren, ReactNode } from "react";
|
import type { PropsWithChildren, ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
@@ -32,7 +33,10 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
disabledByAdmin = false,
|
disabledByAdmin = false,
|
||||||
...viewProps
|
...viewProps
|
||||||
}) => {
|
}) => {
|
||||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
const { t } = useTranslation();
|
||||||
|
const effectiveSubtitle = disabledByAdmin
|
||||||
|
? t("home.settings.disabled_by_admin")
|
||||||
|
: subtitle;
|
||||||
const isDisabled = disabled || disabledByAdmin;
|
const isDisabled = disabled || disabledByAdmin;
|
||||||
if (onPress)
|
if (onPress)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,11 +20,10 @@ import { Button } from "@/components/Button";
|
|||||||
import { Input } from "@/components/common/Input";
|
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 { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
|
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import type {
|
import type {
|
||||||
AccountSecurityType,
|
AccountSecurityType,
|
||||||
SavedServer,
|
SavedServer,
|
||||||
@@ -38,13 +37,11 @@ export const Login: React.FC = () => {
|
|||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const {
|
const {
|
||||||
setServer,
|
setServer,
|
||||||
login,
|
login,
|
||||||
removeServer,
|
removeServer,
|
||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
stopQuickConnectPolling,
|
|
||||||
loginWithSavedCredential,
|
loginWithSavedCredential,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
} = useJellyfin();
|
} = useJellyfin();
|
||||||
@@ -67,32 +64,6 @@ export const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Quick Connect code shown in the in-app sheet while polling for authorization
|
|
||||||
const [quickConnectCode, setQuickConnectCode] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Close the code sheet as soon as the session is authorized — the native
|
|
||||||
// Alert used before had no programmatic dismiss and stayed open after login.
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) setQuickConnectCode(null);
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
// Stop Quick Connect polling when leaving the login page (parity with TVLogin)
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
stopQuickConnectPolling();
|
|
||||||
};
|
|
||||||
}, [stopQuickConnectPolling]);
|
|
||||||
|
|
||||||
// Going back to server selection keeps this component mounted (same screen,
|
|
||||||
// different state), so the unmount cleanup above doesn't run. Without this a
|
|
||||||
// code authorized after leaving would silently log the user in later.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!api?.basePath) {
|
|
||||||
stopQuickConnectPolling();
|
|
||||||
setQuickConnectCode(null);
|
|
||||||
}
|
|
||||||
}, [api?.basePath, stopQuickConnectPolling]);
|
|
||||||
|
|
||||||
// Save account state
|
// Save account state
|
||||||
const [saveAccount, setSaveAccount] = useState(false);
|
const [saveAccount, setSaveAccount] = useState(false);
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
@@ -175,7 +146,7 @@ export const Login: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -288,7 +259,15 @@ export const Login: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
setQuickConnectCode(code);
|
Alert.alert(
|
||||||
|
t("login.quick_connect"),
|
||||||
|
t("login.enter_code_to_login", { code: code }),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("login.got_it"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -423,7 +402,7 @@ export const Login: React.FC = () => {
|
|||||||
{t("server.enter_url_to_jellyfin_server")}
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
aria-label={t("server.server_url")}
|
aria-label='Server URL'
|
||||||
placeholder={t("server.server_url_placeholder")}
|
placeholder={t("server.server_url_placeholder")}
|
||||||
onChangeText={setServerURL}
|
onChangeText={setServerURL}
|
||||||
value={serverURL}
|
value={serverURL}
|
||||||
@@ -474,13 +453,6 @@ export const Login: React.FC = () => {
|
|||||||
onSave={handleSaveAccountConfirm}
|
onSave={handleSaveAccountConfirm}
|
||||||
username={pendingLogin?.username || credentials.username}
|
username={pendingLogin?.username || credentials.username}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dismissing only hides the code — polling continues so the login still
|
|
||||||
completes if the code is authorized from another device afterwards. */}
|
|
||||||
<QuickConnectCodeModal
|
|
||||||
code={quickConnectCode}
|
|
||||||
onClose={() => setQuickConnectCode(null)}
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { requireOptionalNativeModule } from "expo-modules-core";
|
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Button } from "../Button";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** The Quick Connect code to display, or null when hidden. */
|
|
||||||
code: string | null;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the Quick Connect code while the app polls for authorization.
|
|
||||||
* In-app sheet instead of a native Alert so it can dismiss itself once the
|
|
||||||
* session is authorized — a native alert has no programmatic dismiss and
|
|
||||||
* lingers over the app after login completes.
|
|
||||||
*/
|
|
||||||
export const QuickConnectCodeModal: React.FC<Props> = ({ code, onClose }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
||||||
const snapPoints = useMemo(() => ["50%"], []);
|
|
||||||
const isPresentedRef = useRef(false);
|
|
||||||
|
|
||||||
// Keep the last code around so the dismiss animation doesn't flash empty
|
|
||||||
// when the parent clears the code to close the sheet.
|
|
||||||
const lastCodeRef = useRef<string | null>(null);
|
|
||||||
if (code) lastCodeRef.current = code;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (code) {
|
|
||||||
bottomSheetModalRef.current?.present();
|
|
||||||
} else if (isPresentedRef.current) {
|
|
||||||
bottomSheetModalRef.current?.dismiss();
|
|
||||||
isPresentedRef.current = false;
|
|
||||||
}
|
|
||||||
}, [code]);
|
|
||||||
|
|
||||||
const handleSheetChanges = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (index >= 0) {
|
|
||||||
isPresentedRef.current = true;
|
|
||||||
} else if (index === -1 && isPresentedRef.current) {
|
|
||||||
isPresentedRef.current = false;
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
|
||||||
(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const copyCode = useCallback(async () => {
|
|
||||||
const value = code ?? lastCodeRef.current;
|
|
||||||
if (!value) return;
|
|
||||||
// Builds that don't ship the expo-clipboard native module yet: probe with
|
|
||||||
// requireOptionalNativeModule (returns null instead of throwing/logging)
|
|
||||||
// and skip — importing the JS wrapper there would error out.
|
|
||||||
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
|
||||||
const Clipboard = await import("expo-clipboard");
|
|
||||||
await Clipboard.setStringAsync(value);
|
|
||||||
toast.success(t("login.code_copied"));
|
|
||||||
}, [code, t]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
snapPoints={snapPoints}
|
|
||||||
onChange={handleSheetChanges}
|
|
||||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
|
||||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
|
||||||
backdropComponent={renderBackdrop}
|
|
||||||
>
|
|
||||||
<BottomSheetView
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
paddingLeft: Math.max(16, insets.left),
|
|
||||||
paddingRight: Math.max(16, insets.right),
|
|
||||||
paddingBottom: Math.max(16, insets.bottom),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className='flex-1'>
|
|
||||||
<Text className='font-bold text-2xl text-neutral-100'>
|
|
||||||
{t("login.quick_connect")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className='mt-6 p-6 border border-neutral-800 rounded-xl bg-neutral-900 flex flex-row items-center justify-center'
|
|
||||||
onPress={copyCode}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className='text-center font-bold text-5xl text-neutral-100'
|
|
||||||
style={{ letterSpacing: 10 }}
|
|
||||||
>
|
|
||||||
{code ?? lastCodeRef.current}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='copy-outline'
|
|
||||||
size={22}
|
|
||||||
color='white'
|
|
||||||
style={{ opacity: 0.4, marginLeft: 16 }}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text className='mt-2 text-neutral-500 text-center text-xs'>
|
|
||||||
{t("login.tap_code_to_copy")}
|
|
||||||
</Text>
|
|
||||||
<Text className='mt-3 mb-5 text-neutral-400 text-center px-4'>
|
|
||||||
{t("login.quick_connect_instructions")}
|
|
||||||
</Text>
|
|
||||||
<Button className='mt-auto' color='purple' onPress={onClose}>
|
|
||||||
{t("login.got_it")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occurred");
|
: t("login.an_unexpected_error_occured");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occurred");
|
: t("login.an_unexpected_error_occured");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export const JellyseerrSettings = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
// Only the server URL is admin-lockable — the password stays editable so
|
||||||
|
// the user can still sign in to the admin-pinned Jellyseerr server.
|
||||||
|
const urlLocked = pluginSettings?.jellyseerrServerUrl?.locked === true;
|
||||||
|
|
||||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
@@ -30,22 +33,25 @@ export const JellyseerrSettings = () => {
|
|||||||
string | undefined
|
string | undefined
|
||||||
>(settings?.jellyseerrServerUrl || undefined);
|
>(settings?.jellyseerrServerUrl || undefined);
|
||||||
|
|
||||||
|
// When the URL is admin-pinned, ignore any locally-typed value so the login
|
||||||
|
// targets the same server the (read-only) input displays.
|
||||||
|
const effectiveServerUrl = urlLocked
|
||||||
|
? settings?.jellyseerrServerUrl
|
||||||
|
: jellyseerrServerUrl || settings?.jellyseerrServerUrl;
|
||||||
|
|
||||||
const loginToJellyseerrMutation = useMutation({
|
const loginToJellyseerrMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
if (!effectiveServerUrl) throw new Error("Missing server url");
|
||||||
throw new Error("Missing server url");
|
|
||||||
if (!user?.Name)
|
if (!user?.Name)
|
||||||
throw new Error("Missing required information for login");
|
throw new Error("Missing required information for login");
|
||||||
const jellyseerrTempApi = new JellyseerrApi(
|
const jellyseerrTempApi = new JellyseerrApi(effectiveServerUrl);
|
||||||
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
|
|
||||||
);
|
|
||||||
const testResult = await jellyseerrTempApi.test();
|
const testResult = await jellyseerrTempApi.test();
|
||||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
||||||
},
|
},
|
||||||
onSuccess: (user) => {
|
onSuccess: (user) => {
|
||||||
setJellyseerrUser(user);
|
setJellyseerrUser(user);
|
||||||
updateSettings({ jellyseerrServerUrl });
|
updateSettings({ jellyseerrServerUrl: effectiveServerUrl });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(t("jellyseerr.failed_to_login"));
|
toast.error(t("jellyseerr.failed_to_login"));
|
||||||
@@ -115,30 +121,41 @@ 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'>
|
||||||
<Text className='font-bold mb-1'>
|
<View style={{ opacity: urlLocked ? 0.5 : 1 }}>
|
||||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
<Text className='font-bold mb-1'>
|
||||||
</Text>
|
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||||
<View className='flex flex-col shrink mb-2'>
|
|
||||||
<Text className='text-xs text-gray-600'>
|
|
||||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
<View className='flex flex-col shrink mb-2'>
|
||||||
<Input
|
<Text className='text-xs text-gray-600'>
|
||||||
className='border border-neutral-800 mb-2'
|
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||||
placeholder={t(
|
</Text>
|
||||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
</View>
|
||||||
|
<Input
|
||||||
|
className='border border-neutral-800 mb-2'
|
||||||
|
placeholder={t(
|
||||||
|
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||||
|
)}
|
||||||
|
value={
|
||||||
|
urlLocked
|
||||||
|
? settings?.jellyseerrServerUrl
|
||||||
|
: (jellyseerrServerUrl ?? settings?.jellyseerrServerUrl)
|
||||||
|
}
|
||||||
|
defaultValue={
|
||||||
|
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||||
|
}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
onChangeText={setjellyseerrServerUrl}
|
||||||
|
editable={!urlLocked && !loginToJellyseerrMutation.isPending}
|
||||||
|
/>
|
||||||
|
{urlLocked && (
|
||||||
|
<Text className='text-xs text-red-600 mb-2'>
|
||||||
|
{t("home.settings.disabled_by_admin")}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
</View>
|
||||||
defaultValue={
|
|
||||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
|
||||||
}
|
|
||||||
keyboardType='url'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='URL'
|
|
||||||
onChangeText={setjellyseerrServerUrl}
|
|
||||||
editable={!loginToJellyseerrMutation.isPending}
|
|
||||||
/>
|
|
||||||
<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>
|
>
|
||||||
|
<Switch
|
||||||
<View className='flex flex-row items-center justify-between mt-2'>
|
value={isEnabled}
|
||||||
<Text className='text-white'>
|
disabled={locked}
|
||||||
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
|
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||||
</Text>
|
/>
|
||||||
|
</ListItem>
|
||||||
<Switch
|
</ListGroup>
|
||||||
value={isEnabled}
|
|
||||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
|
||||||
trackColor={{ false: "#555", true: "purple" }}
|
|
||||||
thumbColor={isEnabled ? "#fff" : "#ccc"}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</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/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'
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
type BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
@@ -6,13 +5,11 @@ import {
|
|||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { requireOptionalNativeModule } from "expo-modules-core";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||||
import { Pressable } from "react-native-gesture-handler";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
@@ -61,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.quick_connect.success"),
|
t("home.settings.quick_connect.success"),
|
||||||
t("home.settings.quick_connect.quick_connect_authorized"),
|
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||||
);
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
bottomSheetModalRef?.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
@@ -82,15 +79,6 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
}
|
}
|
||||||
}, [api, user, quickConnectCode]);
|
}, [api, user, quickConnectCode]);
|
||||||
|
|
||||||
const pasteCode = useCallback(async () => {
|
|
||||||
// Builds without the expo-clipboard native module: probe first (no-op).
|
|
||||||
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
|
||||||
const Clipboard = await import("expo-clipboard");
|
|
||||||
const text = await Clipboard.getStringAsync();
|
|
||||||
const digits = (text || "").replace(/\D/g, "").slice(0, 6);
|
|
||||||
if (digits) setQuickConnectCode(digits);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -142,15 +130,6 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
style={{ paddingHorizontal: 16 }}
|
style={{ paddingHorizontal: 16 }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Pressable
|
|
||||||
onPress={pasteCode}
|
|
||||||
className='flex-row items-center justify-center self-center'
|
|
||||||
>
|
|
||||||
<Feather name='clipboard' size={15} color='#a3a3a3' />
|
|
||||||
<Text className='text-neutral-400 ml-2'>
|
|
||||||
{t("home.settings.quick_connect.paste_code")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
|
|||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
|
|||||||
used: (app.total - app.remaining) / app.total,
|
used: (app.total - app.remaining) / app.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// Keep the bar moving while a download is writing to disk.
|
|
||||||
refetchInterval: 10 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeleteClicked = () => {
|
const onDeleteClicked = async () => {
|
||||||
Alert.alert(
|
try {
|
||||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
await deleteAllFiles();
|
||||||
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
successHapticFeedback();
|
||||||
[
|
} catch (_e) {
|
||||||
{
|
errorHapticFeedback();
|
||||||
text: t("common.cancel"),
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
style: "cancel",
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.ok"),
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => {
|
|
||||||
try {
|
|
||||||
await deleteAllFiles();
|
|
||||||
successHapticFeedback();
|
|
||||||
} catch (_e) {
|
|
||||||
errorHapticFeedback();
|
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
|
||||||
} finally {
|
|
||||||
// Reflect the freed space immediately instead of waiting for
|
|
||||||
// the next poll.
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculatePercentage = (value: number, total: number) => {
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
|
|||||||
@@ -54,7 +54,6 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.18",
|
"expo-build-properties": "~56.0.18",
|
||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
"expo-clipboard": "~56.0.4",
|
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
|
|||||||
@@ -96,24 +96,5 @@ export function getDownloadedItemSize(id: string): number {
|
|||||||
*/
|
*/
|
||||||
export function calculateTotalDownloadedSize(): number {
|
export function calculateTotalDownloadedSize(): number {
|
||||||
const items = getAllDownloadedItems();
|
const items = getAllDownloadedItems();
|
||||||
return items.reduce((sum, item) => {
|
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
||||||
// Trickplay bytes count too — getDownloadedItemSize models per-item size
|
|
||||||
// as video + trickplay, the total must match.
|
|
||||||
const trickplaySize = item.trickPlayData?.size ?? 0;
|
|
||||||
// Read the live file size on disk so the total reflects actual usage and
|
|
||||||
// self-heals items whose stored videoFileSize is 0 (old schema, or
|
|
||||||
// `fileInfo.size` was undefined at download time). Fall back to the stored
|
|
||||||
// value if the file can't be stat'd.
|
|
||||||
if (item.videoFilePath) {
|
|
||||||
try {
|
|
||||||
const file = new File(filePathToUri(item.videoFilePath));
|
|
||||||
if (file.exists) {
|
|
||||||
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to stat downloaded file for size:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sum + (item.videoFileSize ?? 0) + trickplaySize;
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,24 +289,7 @@ export function useDownloadOperations({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const appSizeUsage = useCallback(async () => {
|
const appSizeUsage = useCallback(async () => {
|
||||||
let totalSize = calculateTotalDownloadedSize();
|
const totalSize = calculateTotalDownloadedSize();
|
||||||
|
|
||||||
// Also count in-progress downloads (they write straight to their final
|
|
||||||
// path) so the growing file shows up as app usage instead of drifting
|
|
||||||
// into the generic device share until completion.
|
|
||||||
for (const process of processes) {
|
|
||||||
try {
|
|
||||||
const file = new File(
|
|
||||||
Paths.document,
|
|
||||||
`${generateFilename(process.item)}.mp4`,
|
|
||||||
);
|
|
||||||
if (file.exists) {
|
|
||||||
totalSize += file.size ?? 0;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// File not created yet — ignore.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||||
@@ -327,7 +310,7 @@ export function useDownloadOperations({
|
|||||||
appSize: totalSize,
|
appSize: totalSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [processes]);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t(
|
t(
|
||||||
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
|
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,18 @@
|
|||||||
"login_button": "Log in",
|
"login_button": "Log in",
|
||||||
"quick_connect": "Quick Connect",
|
"quick_connect": "Quick Connect",
|
||||||
"enter_code_to_login": "Enter code {{code}} to log in",
|
"enter_code_to_login": "Enter code {{code}} to log in",
|
||||||
"quick_connect_instructions": "Enter this code on a signed-in device — you'll be logged in automatically.",
|
|
||||||
"tap_code_to_copy": "Tap the code to copy it",
|
|
||||||
"code_copied": "Code copied",
|
|
||||||
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
|
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
|
||||||
"got_it": "Got it",
|
"got_it": "Got it",
|
||||||
"connection_failed": "Connection failed",
|
"connection_failed": "Connection failed",
|
||||||
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
||||||
"an_unexpected_error_occurred": "An unexpected error occurred",
|
"an_unexpected_error_occured": "An unexpected error occurred",
|
||||||
"change_server": "Change server",
|
"change_server": "Change server",
|
||||||
"invalid_username_or_password": "Invalid username or password",
|
"invalid_username_or_password": "Invalid username or password",
|
||||||
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
||||||
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
||||||
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
||||||
"there_is_a_server_error": "There is a server error",
|
"there_is_a_server_error": "There is a server error",
|
||||||
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
||||||
"too_old_server_text": "Unsupported Jellyfin server discovered",
|
"too_old_server_text": "Unsupported Jellyfin server discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
@@ -36,7 +33,6 @@
|
|||||||
"connect_button": "Connect",
|
"connect_button": "Connect",
|
||||||
"previous_servers": "Previous servers",
|
"previous_servers": "Previous servers",
|
||||||
"clear_button": "Clear all",
|
"clear_button": "Clear all",
|
||||||
"server_url": "Server URL",
|
|
||||||
"swipe_to_remove": "Swipe to remove",
|
"swipe_to_remove": "Swipe to remove",
|
||||||
"search_for_local_servers": "Search for local servers",
|
"search_for_local_servers": "Search for local servers",
|
||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
@@ -122,6 +118,7 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Settings",
|
"settings_title": "Settings",
|
||||||
|
"disabled_by_admin": "Disabled by admin",
|
||||||
"log_out_button": "Log out",
|
"log_out_button": "Log out",
|
||||||
"switch_user": {
|
"switch_user": {
|
||||||
"title": "Switch user",
|
"title": "Switch user",
|
||||||
@@ -192,11 +189,10 @@
|
|||||||
"authorize_button": "Authorize Quick Connect",
|
"authorize_button": "Authorize Quick Connect",
|
||||||
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"quick_connect_authorized": "Quick Connect authorized",
|
"quick_connect_autorized": "Quick Connect authorized",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Invalid code",
|
"invalid_code": "Invalid code",
|
||||||
"authorize": "Authorize",
|
"authorize": "Authorize"
|
||||||
"paste_code": "Paste code"
|
|
||||||
},
|
},
|
||||||
"media_controls": {
|
"media_controls": {
|
||||||
"media_controls_title": "Media controls",
|
"media_controls_title": "Media controls",
|
||||||
@@ -303,7 +299,7 @@
|
|||||||
"show_custom_menu_links": "Show custom menu links",
|
"show_custom_menu_links": "Show custom menu links",
|
||||||
"show_large_home_carousel": "Show large home carousel (beta)",
|
"show_large_home_carousel": "Show large home carousel (beta)",
|
||||||
"hide_libraries": "Hide libraries",
|
"hide_libraries": "Hide libraries",
|
||||||
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||||
"disable_haptic_feedback": "Disable haptic feedback",
|
"disable_haptic_feedback": "Disable haptic feedback",
|
||||||
"default_quality": "Default quality",
|
"default_quality": "Default quality",
|
||||||
"default_playback_speed": "Default playback speed",
|
"default_playback_speed": "Default playback speed",
|
||||||
@@ -375,6 +371,7 @@
|
|||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
"refreshed": "Settings refreshed from server",
|
"refreshed": "Settings refreshed from server",
|
||||||
|
"refresh_failed": "Couldn't refresh settings from server",
|
||||||
"disabled": "Streamystats disabled"
|
"disabled": "Streamystats disabled"
|
||||||
},
|
},
|
||||||
"refresh_from_server": "Refresh settings from server"
|
"refresh_from_server": "Refresh settings from server"
|
||||||
@@ -389,8 +386,6 @@
|
|||||||
"device_usage": "Device {{availableSpace}}%",
|
"device_usage": "Device {{availableSpace}}%",
|
||||||
"size_used": "{{used}} of {{total}} used",
|
"size_used": "{{used}} of {{total}} used",
|
||||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
"delete_all_downloaded_files": "Delete all downloaded files",
|
||||||
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
|
||||||
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
|
||||||
"music_cache_title": "Music cache",
|
"music_cache_title": "Music cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear music cache",
|
"clear_music_cache": "Clear music cache",
|
||||||
@@ -605,7 +600,7 @@
|
|||||||
"mpv_player_title": "MPV player",
|
"mpv_player_title": "MPV player",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
"client_error": "Client error",
|
"client_error": "Client error",
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
"message_from_server": "Message from server: {{message}}",
|
"message_from_server": "Message from server: {{message}}",
|
||||||
|
|||||||
@@ -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