mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-18 10:14:17 +01:00
Compare commits
5 Commits
fix/plugin
...
fix/librar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0202c1caa9 | ||
|
|
d2db319593 | ||
|
|
2e79815d78 | ||
|
|
43ce50fe70 | ||
|
|
bd0eef0407 |
@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { ScrollView } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function JellyseerrPluginPage() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
@@ -13,9 +16,12 @@ export default function JellyseerrPluginPage() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className='p-4'>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||
className='p-4'
|
||||
>
|
||||
<JellyseerrSettings />
|
||||
</View>
|
||||
</DisabledSetting>
|
||||
</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 DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function KefinTweaksPage() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
@@ -13,9 +16,12 @@ export default function KefinTweaksPage() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className='px-4'>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
||||
className='p-4'
|
||||
>
|
||||
<KefinTweaksSettings />
|
||||
</View>
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Linking,
|
||||
@@ -14,24 +14,22 @@ import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function MarlinSearchPage() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
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) => {
|
||||
updateSettings({
|
||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||
@@ -43,8 +41,15 @@ export default function MarlinSearchPage() {
|
||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||
};
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
return (
|
||||
pluginSettings?.searchEngine?.locked === true &&
|
||||
pluginSettings?.marlinServerUrl?.locked === true
|
||||
);
|
||||
}, [pluginSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!marlinUrlLocked) {
|
||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
||||
@@ -55,7 +60,7 @@ export default function MarlinSearchPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [navigation, value, marlinUrlLocked, t]);
|
||||
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
@@ -67,42 +72,52 @@ export default function MarlinSearchPage() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className='px-4'>
|
||||
<DisabledSetting disabled={disabled} className='px-4'>
|
||||
<ListGroup>
|
||||
{/* disabledByAdmin renders the "Disabled by admin" notice as the row's
|
||||
subtitle (same pattern as the Streamystats settings) — no clipping. */}
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||
)}
|
||||
disabledByAdmin={searchEngineLocked}
|
||||
// Streamystats owns the search engine while configured — block the
|
||||
// row tap too, not just the Switch, so it can't force "Jellyfin".
|
||||
disabled={hasStreamystats}
|
||||
onPress={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
<DisabledSetting
|
||||
disabled={
|
||||
pluginSettings?.searchEngine?.locked === true ||
|
||||
!!pluginSettings?.streamyStatsServerUrl?.value
|
||||
}
|
||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.searchEngine === "Marlin"}
|
||||
disabled={searchEngineLocked || hasStreamystats}
|
||||
onValueChange={(val) => {
|
||||
updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||
)}
|
||||
onPress={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
>
|
||||
<Switch
|
||||
value={settings.searchEngine === "Marlin"}
|
||||
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
searchEngine: value ? "Marlin" : "Jellyfin",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</DisabledSetting>
|
||||
</ListGroup>
|
||||
|
||||
<ListGroup className='mt-2'>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.marlin_search.url")}
|
||||
disabledByAdmin={marlinUrlLocked}
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||
showText={!pluginSettings?.searchEngine?.locked}
|
||||
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
|
||||
editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
|
||||
className='text-white text-right flex-1'
|
||||
editable={settings.searchEngine === "Marlin"}
|
||||
className='text-white'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||
)}
|
||||
@@ -113,16 +128,15 @@ export default function MarlinSearchPage() {
|
||||
textContentType='URL'
|
||||
onChangeText={(text) => setValue(text)}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
</View>
|
||||
</DisabledSetting>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
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 { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function PluginsPage() {
|
||||
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 (
|
||||
<ScrollView
|
||||
@@ -37,17 +18,6 @@ export default function PluginsPage() {
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<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>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,12 @@ export default function StreamystatsPage() {
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const {
|
||||
settings,
|
||||
updateSettings,
|
||||
pluginSettings,
|
||||
refreshStreamyfinPluginSettings,
|
||||
} = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
// Local state for all editable fields
|
||||
@@ -44,21 +49,7 @@ export default function StreamystatsPage() {
|
||||
);
|
||||
|
||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||
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 isStreamystatsEnabled = !!url;
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
@@ -122,6 +113,17 @@ export default function StreamystatsPage() {
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -144,7 +146,7 @@ export default function StreamystatsPage() {
|
||||
placeholder={t(
|
||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||
)}
|
||||
value={effectiveUrl}
|
||||
value={url}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
@@ -169,18 +171,11 @@ export default function StreamystatsPage() {
|
||||
>
|
||||
<ListItem
|
||||
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
|
||||
value={
|
||||
searchLocked
|
||||
? settings?.searchEngine === "Streamystats"
|
||||
: useForSearch
|
||||
}
|
||||
disabled={!isStreamystatsEnabled || searchLocked}
|
||||
value={useForSearch}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
onValueChange={setUseForSearch}
|
||||
/>
|
||||
</ListItem>
|
||||
@@ -188,62 +183,52 @@ export default function StreamystatsPage() {
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||
)}
|
||||
disabledByAdmin={movieRecsLocked}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
value={
|
||||
movieRecsLocked
|
||||
? (settings?.streamyStatsMovieRecommendations ?? false)
|
||||
: movieRecs
|
||||
}
|
||||
value={movieRecs}
|
||||
onValueChange={setMovieRecs}
|
||||
disabled={!isStreamystatsEnabled || movieRecsLocked}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||
)}
|
||||
disabledByAdmin={seriesRecsLocked}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
value={
|
||||
seriesRecsLocked
|
||||
? (settings?.streamyStatsSeriesRecommendations ?? false)
|
||||
: seriesRecs
|
||||
}
|
||||
value={seriesRecs}
|
||||
onValueChange={setSeriesRecs}
|
||||
disabled={!isStreamystatsEnabled || seriesRecsLocked}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||
)}
|
||||
disabledByAdmin={promotedWatchlistsLocked}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
value={
|
||||
promotedWatchlistsLocked
|
||||
? (settings?.streamyStatsPromotedWatchlists ?? false)
|
||||
: promotedWatchlists
|
||||
}
|
||||
value={promotedWatchlists}
|
||||
onValueChange={setPromotedWatchlists}
|
||||
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||
disabledByAdmin={hideWatchlistsTabLocked}
|
||||
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||
>
|
||||
<Switch
|
||||
value={
|
||||
hideWatchlistsTabLocked
|
||||
? (settings?.hideWatchlistsTab ?? false)
|
||||
: hideWatchlistsTab
|
||||
}
|
||||
value={hideWatchlistsTab}
|
||||
onValueChange={setHideWatchlistsTab}
|
||||
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
@@ -251,6 +236,15 @@ export default function StreamystatsPage() {
|
||||
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||
</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 */}
|
||||
{!isUrlLocked && isStreamystatsEnabled && (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
TouchableItemRouter,
|
||||
} from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
@@ -134,6 +135,12 @@ const page: React.FC = () => {
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: collection?.Name || "" });
|
||||
setSortOrder([SortOrderOption.Ascending]);
|
||||
// Collections open with a clean filter slate: the genre/year/tag atoms are
|
||||
// global, so without this the previously viewed library's selection bleeds
|
||||
// in (libraries now keep their own per-library memory).
|
||||
setSelectedGenres([]);
|
||||
setSelectedYears([]);
|
||||
setSelectedTags([]);
|
||||
|
||||
if (!collection) return;
|
||||
|
||||
@@ -204,40 +211,39 @@ const page: React.FC = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: [
|
||||
"collection-items",
|
||||
collectionId,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
!lastPage?.TotalRecordCount ||
|
||||
lastPage?.TotalRecordCount === 0
|
||||
)
|
||||
return undefined;
|
||||
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
}
|
||||
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"collection-items",
|
||||
collectionId,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
!lastPage?.TotalRecordCount ||
|
||||
lastPage?.TotalRecordCount === 0
|
||||
)
|
||||
return undefined;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!collection,
|
||||
});
|
||||
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!collection,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
return (
|
||||
@@ -326,7 +332,7 @@ const page: React.FC = () => {
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
component: <ResetFiltersButton libraryId={collectionId} />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
@@ -466,7 +472,6 @@ const page: React.FC = () => {
|
||||
setSortBy,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -633,43 +638,45 @@ const page: React.FC = () => {
|
||||
// Mobile return
|
||||
if (!Platform.isTV) {
|
||||
return (
|
||||
<FlashList
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("search.no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={nrOfCols}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
<FilterSheetProvider>
|
||||
<FlashList
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("search.no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{ paddingBottom: 24 }}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={nrOfCols}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{ paddingBottom: 24 }}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSheetProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
getItemsApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { FlashList, type FlashListRef } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import {
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
TouchableItemRouter,
|
||||
} from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
@@ -44,6 +45,7 @@ import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useFilterReset } from "@/hooks/useFilterReset";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||
@@ -55,7 +57,9 @@ import {
|
||||
FilterByPreferenceAtom,
|
||||
filterByAtom,
|
||||
genreFilterAtom,
|
||||
genrePreferenceAtom,
|
||||
getFilterByPreference,
|
||||
getMultiFilterPreference,
|
||||
getSortByPreference,
|
||||
getSortOrderPreference,
|
||||
SortByOption,
|
||||
@@ -66,11 +70,12 @@ import {
|
||||
sortOrderAtom,
|
||||
sortOrderOptions,
|
||||
sortOrderPreferenceAtom,
|
||||
tagPreferenceAtom,
|
||||
tagsFilterAtom,
|
||||
useFilterOptions,
|
||||
yearFilterAtom,
|
||||
yearPreferenceAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
@@ -108,6 +113,9 @@ const Page = () => {
|
||||
const [sortOrderPreference, setOrderByPreference] = useAtom(
|
||||
sortOrderPreferenceAtom,
|
||||
);
|
||||
const [genrePreference, setGenrePreference] = useAtom(genrePreferenceAtom);
|
||||
const [yearPreference, setYearPreference] = useAtom(yearPreferenceAtom);
|
||||
const [tagPreference, setTagPreference] = useAtom(tagPreferenceAtom);
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
|
||||
@@ -205,6 +213,13 @@ const Page = () => {
|
||||
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||
_setFilterBy(fp ? [fp] : []);
|
||||
}
|
||||
|
||||
// Genres / years / tags: per-library saved preference (no URL params), so
|
||||
// switching libraries restores each library's own selection instead of
|
||||
// bleeding the previous one.
|
||||
setSelectedGenres(getMultiFilterPreference(libraryId, genrePreference));
|
||||
setSelectedYears(getMultiFilterPreference(libraryId, yearPreference));
|
||||
setSelectedTags(getMultiFilterPreference(libraryId, tagPreference));
|
||||
}, [
|
||||
libraryId,
|
||||
sortOrderPreference,
|
||||
@@ -213,6 +228,12 @@ const Page = () => {
|
||||
_setSortBy,
|
||||
filterByPreference,
|
||||
_setFilterBy,
|
||||
genrePreference,
|
||||
yearPreference,
|
||||
tagPreference,
|
||||
setSelectedGenres,
|
||||
setSelectedYears,
|
||||
setSelectedTags,
|
||||
searchParams.sortBy,
|
||||
searchParams.sortOrder,
|
||||
searchParams.filterBy,
|
||||
@@ -257,6 +278,32 @@ const Page = () => {
|
||||
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
|
||||
);
|
||||
|
||||
// Genres / years / tags: save the per-library memory then update the active
|
||||
// atom (mirrors setSortBy; avoids a save-effect that would corrupt on switch).
|
||||
const setGenres = useCallback(
|
||||
(genres: string[]) => {
|
||||
setGenrePreference({ ...genrePreference, [libraryId]: genres });
|
||||
setSelectedGenres(genres);
|
||||
},
|
||||
[libraryId, genrePreference, setGenrePreference, setSelectedGenres],
|
||||
);
|
||||
|
||||
const setYears = useCallback(
|
||||
(years: string[]) => {
|
||||
setYearPreference({ ...yearPreference, [libraryId]: years });
|
||||
setSelectedYears(years);
|
||||
},
|
||||
[libraryId, yearPreference, setYearPreference, setSelectedYears],
|
||||
);
|
||||
|
||||
const setTags = useCallback(
|
||||
(tags: string[]) => {
|
||||
setTagPreference({ ...tagPreference, [libraryId]: tags });
|
||||
setSelectedTags(tags);
|
||||
},
|
||||
[libraryId, tagPreference, setTagPreference, setSelectedTags],
|
||||
);
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
if (Platform.isTV) {
|
||||
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||
@@ -415,6 +462,29 @@ const Page = () => {
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const flashListRef = useRef<FlashListRef<BaseItemDto>>(null);
|
||||
|
||||
// Jump the grid to the top when the filters/sort change (incl. reset).
|
||||
const filterSignature = `${selectedGenres}|${selectedYears}|${selectedTags}|${sortBy[0]}|${sortOrder[0]}|${filterBy}`;
|
||||
const pendingScrollTopRef = useRef(false);
|
||||
|
||||
// Instant feedback: pin to the top the moment the filters change, without
|
||||
// waiting for the new fetch — and flag a re-pin for once it settles.
|
||||
useEffect(() => {
|
||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||
pendingScrollTopRef.current = true;
|
||||
}, [filterSignature]);
|
||||
|
||||
// Safety net: FlashList can restore the previous offset as the filtered list
|
||||
// grows, so re-pin once the fetch settles. Pagination keeps the same
|
||||
// signature, so it never re-pins.
|
||||
useEffect(() => {
|
||||
if (pendingScrollTopRef.current && !isFetching) {
|
||||
pendingScrollTopRef.current = false;
|
||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||
}
|
||||
}, [isFetching, flatData]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<TouchableItemRouter
|
||||
@@ -530,7 +600,6 @@ const Page = () => {
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
const generalFilters = useFilterOptions();
|
||||
const settings = useSettings();
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<FlatList
|
||||
@@ -545,7 +614,7 @@ const Page = () => {
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
component: <ResetFiltersButton libraryId={libraryId} />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
@@ -564,7 +633,7 @@ const Page = () => {
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
set={setGenres}
|
||||
values={selectedGenres}
|
||||
title={t("library.filters.genres")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
@@ -591,7 +660,7 @@ const Page = () => {
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
set={setYears}
|
||||
values={selectedYears}
|
||||
title={t("library.filters.years")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
@@ -616,7 +685,7 @@ const Page = () => {
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
set={setTags}
|
||||
values={selectedTags}
|
||||
title={t("library.filters.tags")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
@@ -696,35 +765,23 @@ const Page = () => {
|
||||
api,
|
||||
user?.Id,
|
||||
selectedGenres,
|
||||
setSelectedGenres,
|
||||
setGenres,
|
||||
selectedYears,
|
||||
setSelectedYears,
|
||||
setYears,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
setTags,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
filterBy,
|
||||
setFilter,
|
||||
settings,
|
||||
],
|
||||
);
|
||||
|
||||
// TV Filter bar header
|
||||
const hasActiveFilters =
|
||||
selectedGenres.length > 0 ||
|
||||
selectedYears.length > 0 ||
|
||||
selectedTags.length > 0 ||
|
||||
filterBy.length > 0;
|
||||
|
||||
const resetAllFilters = useCallback(() => {
|
||||
setSelectedGenres([]);
|
||||
setSelectedYears([]);
|
||||
setSelectedTags([]);
|
||||
_setFilterBy([]);
|
||||
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
|
||||
// Filter bar reset + visibility, shared with the mobile ResetFiltersButton so
|
||||
// sort/order can't be forgotten on one path (it used to be reset on neither).
|
||||
const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
|
||||
|
||||
// TV Filter options - with "All" option for clearable filters
|
||||
const tvGenreFilterOptions = useMemo(
|
||||
@@ -818,15 +875,15 @@ const Page = () => {
|
||||
options: tvGenreFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedGenres([]);
|
||||
setGenres([]);
|
||||
} else if (selectedGenres.includes(value)) {
|
||||
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||
setGenres(selectedGenres.filter((g) => g !== value));
|
||||
} else {
|
||||
setSelectedGenres([...selectedGenres, value]);
|
||||
setGenres([...selectedGenres, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setGenres]);
|
||||
|
||||
const handleShowYearFilter = useCallback(() => {
|
||||
showOptions({
|
||||
@@ -834,15 +891,15 @@ const Page = () => {
|
||||
options: tvYearFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedYears([]);
|
||||
setYears([]);
|
||||
} else if (selectedYears.includes(value)) {
|
||||
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||
setYears(selectedYears.filter((y) => y !== value));
|
||||
} else {
|
||||
setSelectedYears([...selectedYears, value]);
|
||||
setYears([...selectedYears, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||
}, [showOptions, t, tvYearFilterOptions, selectedYears, setYears]);
|
||||
|
||||
const handleShowTagFilter = useCallback(() => {
|
||||
showOptions({
|
||||
@@ -850,15 +907,15 @@ const Page = () => {
|
||||
options: tvTagFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedTags([]);
|
||||
setTags([]);
|
||||
} else if (selectedTags.includes(value)) {
|
||||
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||
setTags(selectedTags.filter((tag) => tag !== value));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, value]);
|
||||
setTags([...selectedTags, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||
}, [showOptions, t, tvTagFilterOptions, selectedTags, setTags]);
|
||||
|
||||
const handleShowSortByFilter = useCallback(() => {
|
||||
showOptions({
|
||||
@@ -906,42 +963,45 @@ const Page = () => {
|
||||
// Mobile return
|
||||
if (!Platform.isTV) {
|
||||
return (
|
||||
<FlashList
|
||||
key={orientation}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("library.no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
extraData={[orientation, nrOfCols]}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={nrOfCols}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
<FilterSheetProvider>
|
||||
<FlashList
|
||||
ref={flashListRef}
|
||||
key={orientation}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("library.no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={1}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
extraData={[orientation, nrOfCols]}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={nrOfCols}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={1}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FilterSheetProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1318,7 +1318,7 @@ export default function DirectPlayerPage() {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
t("player.an_error_occurred_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
|
||||
@@ -85,7 +85,8 @@ configureReanimatedLogger({
|
||||
if (!Platform.isTV) {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
@@ -350,9 +351,12 @@ function Layout() {
|
||||
notificationListener.current =
|
||||
Notifications?.addNotificationReceivedListener(
|
||||
(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(
|
||||
"Notification received while app running",
|
||||
notification,
|
||||
"Notification received while app running:",
|
||||
notification.request.content.title,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
@@ -61,6 +62,7 @@ export const BitrateSheet: React.FC<Props> = ({
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
@@ -92,7 +94,10 @@ export const BitrateSheet: React.FC<Props> = ({
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||
onPress={() => setOpen(true)}
|
||||
onPress={() => {
|
||||
setOpen(true);
|
||||
sheetModalRef.current?.present();
|
||||
}}
|
||||
>
|
||||
<Text numberOfLines={1}>
|
||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||
@@ -103,6 +108,7 @@ export const BitrateSheet: React.FC<Props> = ({
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
modalRef={sheetModalRef}
|
||||
title={t("item_card.quality")}
|
||||
data={sorted}
|
||||
values={selected ? [selected] : []}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
@@ -23,6 +24,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
||||
|
||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||
@@ -44,7 +46,10 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
||||
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
|
||||
onPress={() => setOpen(true)}
|
||||
onPress={() => {
|
||||
setOpen(true);
|
||||
sheetModalRef.current?.present();
|
||||
}}
|
||||
>
|
||||
<Text numberOfLines={1}>{selectedName}</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -53,6 +58,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
modalRef={sheetModalRef}
|
||||
title={t("item_card.video")}
|
||||
data={item.MediaSources || []}
|
||||
values={selected ? [selected] : []}
|
||||
|
||||
@@ -16,9 +16,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
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(() => {
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
}, []);
|
||||
const seriesId = items[0]?.SeriesId;
|
||||
return seriesId ? storage.getString(seriesId) : undefined;
|
||||
}, [items[0]?.SeriesId]);
|
||||
|
||||
const deleteSeries = useCallback(
|
||||
async () =>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FilterSheet } from "./FilterSheet";
|
||||
import { useFilterSheet } from "./FilterSheetProvider";
|
||||
|
||||
interface FilterButtonProps<T> extends ViewProps {
|
||||
id: string;
|
||||
@@ -33,22 +35,63 @@ export const FilterButton = <T,>({
|
||||
icon = "filter",
|
||||
...props
|
||||
}: FilterButtonProps<T>) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
// When a FilterSheetProvider is present (library / collections), all buttons
|
||||
// share one sheet so two can never stack. Outside a provider (e.g. logs,
|
||||
// discover), fall back to this button's own standalone sheet.
|
||||
const shared = useFilterSheet();
|
||||
|
||||
const { data: filters } = useQuery<T[]>({
|
||||
const { data: filters, isLoading } = useQuery<T[]>({
|
||||
queryKey: ["filters", title, queryKey, id],
|
||||
queryFn,
|
||||
staleTime: 0,
|
||||
enabled: !!id && !!queryFn && !!queryKey,
|
||||
});
|
||||
|
||||
// Standalone-mode state (unused in shared mode).
|
||||
const [open, setOpen] = useState(false);
|
||||
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
||||
|
||||
const onButtonPress = useCallback(() => {
|
||||
if (shared) {
|
||||
shared.openFilter({
|
||||
key: `${id}:${queryKey}`,
|
||||
id,
|
||||
queryKey,
|
||||
queryFn,
|
||||
title,
|
||||
values: values as unknown[],
|
||||
set: set as (value: unknown[]) => void,
|
||||
renderItemLabel: renderItemLabel as (item: unknown) => React.ReactNode,
|
||||
searchFilter: searchFilter as
|
||||
| ((item: unknown, query: string) => boolean)
|
||||
| undefined,
|
||||
disableSearch,
|
||||
multiple,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// present() must run from the press handler: from an effect after a state
|
||||
// update it silently no-ops on the new architecture and the sheet never
|
||||
// appears.
|
||||
setOpen(true);
|
||||
sheetModalRef.current?.present();
|
||||
}, [
|
||||
shared,
|
||||
id,
|
||||
queryKey,
|
||||
queryFn,
|
||||
title,
|
||||
values,
|
||||
set,
|
||||
renderItemLabel,
|
||||
searchFilter,
|
||||
disableSearch,
|
||||
multiple,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
filters?.length && setOpen(true);
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity onPress={onButtonPress}>
|
||||
<View
|
||||
className={`
|
||||
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
||||
@@ -85,18 +128,22 @@ export const FilterButton = <T,>({
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<FilterSheet<T>
|
||||
title={title}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
data={filters}
|
||||
values={values}
|
||||
set={set}
|
||||
renderItemLabel={renderItemLabel}
|
||||
searchFilter={searchFilter}
|
||||
disableSearch={disableSearch}
|
||||
multiple={multiple}
|
||||
/>
|
||||
{!shared && (
|
||||
<FilterSheet<T>
|
||||
title={title}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
modalRef={sheetModalRef}
|
||||
loading={isLoading}
|
||||
data={filters}
|
||||
values={values}
|
||||
set={set}
|
||||
renderItemLabel={renderItemLabel}
|
||||
searchFilter={searchFilter}
|
||||
disableSearch={disableSearch}
|
||||
multiple={multiple}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,14 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { isEqual } from "lodash";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
StyleSheet,
|
||||
@@ -19,11 +26,21 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Button } from "../Button";
|
||||
import { Input } from "../common/Input";
|
||||
import { Loader } from "../Loader";
|
||||
|
||||
interface Props<T> extends ViewProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
/**
|
||||
* Modal ref the opener must use to present() the sheet from inside its
|
||||
* press handler. On the new architecture with Reanimated 4, present()
|
||||
* called from an effect after a state update silently no-ops — the sheet
|
||||
* mounts nothing. Presenting straight from the gesture handler works.
|
||||
*/
|
||||
modalRef: React.RefObject<BottomSheetModal | null>;
|
||||
data?: T[] | null;
|
||||
/** True while the options are loading — shows a loader inside the sheet. */
|
||||
loading?: boolean;
|
||||
values: T[];
|
||||
set: (value: T[]) => void;
|
||||
title: string;
|
||||
@@ -66,16 +83,18 @@ const LIMIT = 100;
|
||||
export const FilterSheet = <T,>({
|
||||
values,
|
||||
data: _data,
|
||||
loading = false,
|
||||
open,
|
||||
set,
|
||||
setOpen,
|
||||
modalRef,
|
||||
title,
|
||||
searchFilter,
|
||||
renderItemLabel,
|
||||
disableSearch = false,
|
||||
multiple = false,
|
||||
}: Props<T>) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const bottomSheetModalRef = modalRef;
|
||||
const snapPoints = useMemo(() => ["85%"], []);
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -84,19 +103,24 @@ export const FilterSheet = <T,>({
|
||||
const [offset, setOffset] = useState<number>(0);
|
||||
|
||||
const [search, setSearch] = useState<string>("");
|
||||
// Filtering and re-rendering the option list on every keystroke blocks the
|
||||
// JS thread on large lists (2000+ tags); the controlled input then snaps the
|
||||
// native text back to a stale value (lost/reappearing letters). Deferring the
|
||||
// value keeps the keystroke render cheap and runs the list update after.
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
|
||||
const [showSearch, setShowSearch] = useState<boolean>(false);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!search) return _data;
|
||||
if (!deferredSearch) return _data;
|
||||
const results = [];
|
||||
for (let i = 0; i < (_data?.length || 0); i++) {
|
||||
if (_data && searchFilter?.(_data[i], search)) {
|
||||
if (_data && searchFilter?.(_data[i], deferredSearch)) {
|
||||
results.push(_data[i]);
|
||||
}
|
||||
}
|
||||
return results.slice(0, 100);
|
||||
}, [search, _data, searchFilter]);
|
||||
}, [deferredSearch, _data, searchFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || data.length === 0 || disableSearch) return;
|
||||
@@ -127,21 +151,28 @@ export const FilterSheet = <T,>({
|
||||
setData(newData);
|
||||
}, [offset, _data]);
|
||||
|
||||
// Opening is imperative (see the modalRef prop); this effect only closes.
|
||||
// It also never calls dismiss() on a modal that was never presented.
|
||||
const wasPresentedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (open) bottomSheetModalRef.current?.present();
|
||||
else bottomSheetModalRef.current?.dismiss();
|
||||
if (!open && wasPresentedRef.current) {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
if (index === -1) {
|
||||
if (index >= 0) {
|
||||
wasPresentedRef.current = true;
|
||||
} else if (index === -1) {
|
||||
wasPresentedRef.current = false;
|
||||
setOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderData = useMemo(() => {
|
||||
if (search.length > 0 && showSearch) return filteredData;
|
||||
if (deferredSearch.length > 0 && showSearch) return filteredData;
|
||||
return data;
|
||||
}, [search, filteredData, data]);
|
||||
}, [deferredSearch, showSearch, filteredData, data]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
@@ -154,6 +185,54 @@ export const FilterSheet = <T,>({
|
||||
[],
|
||||
);
|
||||
|
||||
// Memoized so typing in the search input (urgent render with an unchanged
|
||||
// deferred value) doesn't rebuild up to 100 row elements per keystroke.
|
||||
const renderedRows = useMemo(
|
||||
() =>
|
||||
renderData?.map((item, index) => (
|
||||
<View key={index}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
// Match the deep-equality rule used to render the selected
|
||||
// state below — option objects are recreated across renders,
|
||||
// so reference checks would re-add an already selected item.
|
||||
const isSelected = values.some((value) => isEqual(value, item));
|
||||
if (multiple) {
|
||||
if (!isSelected) set(values.concat(item));
|
||||
else set(values.filter((value) => !isEqual(value, item)));
|
||||
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, 250);
|
||||
} else {
|
||||
if (!isSelected) {
|
||||
set([item]);
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||
>
|
||||
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
||||
{values.some((i) => isEqual(i, item)) ? (
|
||||
<Ionicons name='radio-button-on' size={24} color='white' />
|
||||
) : (
|
||||
<Ionicons name='radio-button-off' size={24} color='white' />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='h-1 divide-neutral-700 '
|
||||
/>
|
||||
</View>
|
||||
)),
|
||||
[renderData, values, multiple, set, setOpen, renderItemLabel],
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
@@ -182,9 +261,15 @@ export const FilterSheet = <T,>({
|
||||
}}
|
||||
>
|
||||
<Text className='font-bold text-2xl'>{title}</Text>
|
||||
<Text className='mb-2 text-neutral-500'>
|
||||
{t("search.x_items", { count: _data?.length })}
|
||||
</Text>
|
||||
{loading ? (
|
||||
<View className='my-8 flex items-center justify-center'>
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
<Text className='mb-2 text-neutral-500'>
|
||||
{t("search.x_items", { count: _data?.length })}
|
||||
</Text>
|
||||
)}
|
||||
{showSearch && (
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
@@ -203,43 +288,7 @@ export const FilterSheet = <T,>({
|
||||
}}
|
||||
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
||||
>
|
||||
{renderData?.map((item, index) => (
|
||||
<View key={index}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (multiple) {
|
||||
if (!values.includes(item)) set(values.concat(item));
|
||||
else set(values.filter((v) => v !== item));
|
||||
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, 250);
|
||||
} else {
|
||||
if (!values.includes(item)) {
|
||||
set([item]);
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||
>
|
||||
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
||||
{values.some((i) => isEqual(i, item)) ? (
|
||||
<Ionicons name='radio-button-on' size={24} color='white' />
|
||||
) : (
|
||||
<Ionicons name='radio-button-off' size={24} color='white' />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='h-1 divide-neutral-700 '
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
{renderedRows}
|
||||
</View>
|
||||
{data.length < (_data?.length || 0) && (
|
||||
<Button
|
||||
|
||||
74
components/filters/FilterSheetProvider.tsx
Normal file
74
components/filters/FilterSheetProvider.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
createContext,
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type FilterConfig, SharedFilterSheet } from "./SharedFilterSheet";
|
||||
|
||||
interface FilterSheetContextType {
|
||||
openFilter: (config: FilterConfig) => void;
|
||||
}
|
||||
|
||||
const FilterSheetContext = createContext<FilterSheetContextType | null>(null);
|
||||
|
||||
/**
|
||||
* Returns the shared-sheet controller, or null when rendered outside a
|
||||
* FilterSheetProvider — FilterButton then falls back to its own standalone
|
||||
* sheet (used by screens that don't host a provider, e.g. logs / discover).
|
||||
*/
|
||||
export const useFilterSheet = (): FilterSheetContextType | null =>
|
||||
useContext(FilterSheetContext);
|
||||
|
||||
/**
|
||||
* Hosts the single shared filter sheet for a screen. Every FilterButton under
|
||||
* it calls openFilter() to show its options in that one sheet — so two sheets
|
||||
* can never stack regardless of how fast the buttons are tapped. present() runs
|
||||
* synchronously from the button's press handler (the modal is always mounted).
|
||||
*/
|
||||
export const FilterSheetProvider: React.FC<PropsWithChildren> = ({
|
||||
children,
|
||||
}) => {
|
||||
const modalRef = useRef<BottomSheetModal | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [config, setConfig] = useState<FilterConfig | null>(null);
|
||||
|
||||
// First-wins guard. With a single shared sheet there is exactly one source of
|
||||
// truth (this ref) reset on the one close path — so unlike a per-button guard
|
||||
// it can't get stuck on remounts or multiple instances. A second tap during
|
||||
// the first sheet's open animation is ignored; the first tapped filter wins.
|
||||
const openRef = useRef(false);
|
||||
|
||||
const openFilter = useCallback((next: FilterConfig) => {
|
||||
if (openRef.current) return;
|
||||
openRef.current = true;
|
||||
setConfig(next);
|
||||
setOpen(true);
|
||||
modalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
// Single close path for every dismissal (select / swipe / backdrop) — frees
|
||||
// the guard reliably.
|
||||
const closeSheet = useCallback(() => {
|
||||
openRef.current = false;
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ openFilter }), [openFilter]);
|
||||
|
||||
return (
|
||||
<FilterSheetContext.Provider value={value}>
|
||||
{children}
|
||||
<SharedFilterSheet
|
||||
modalRef={modalRef}
|
||||
open={open}
|
||||
setOpen={closeSheet}
|
||||
config={config}
|
||||
/>
|
||||
</FilterSheetContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,40 +1,27 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAtom } from "jotai";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import {
|
||||
filterByAtom,
|
||||
genreFilterAtom,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import { useFilterReset } from "@/hooks/useFilterReset";
|
||||
|
||||
interface Props extends TouchableOpacityProps {}
|
||||
interface Props extends TouchableOpacityProps {
|
||||
libraryId: string;
|
||||
}
|
||||
|
||||
export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom);
|
||||
export const ResetFiltersButton: React.FC<Props> = ({
|
||||
libraryId,
|
||||
...props
|
||||
}) => {
|
||||
const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
|
||||
|
||||
if (
|
||||
selectedGenres.length === 0 &&
|
||||
selectedTags.length === 0 &&
|
||||
selectedYears.length === 0 &&
|
||||
selectedFilters.length === 0
|
||||
) {
|
||||
if (!hasActiveFilters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setSelectedGenres([]);
|
||||
setSelectedTags([]);
|
||||
setSelectedYears([]);
|
||||
setSelectedFilters([]);
|
||||
}}
|
||||
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
|
||||
{...props}
|
||||
// After the spread so a forwarded onPress can't disable the reset.
|
||||
onPress={resetAllFilters}
|
||||
>
|
||||
<Ionicons name='close' size={20} color='white' />
|
||||
</TouchableOpacity>
|
||||
|
||||
275
components/filters/SharedFilterSheet.tsx
Normal file
275
components/filters/SharedFilterSheet.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { isEqual } from "lodash";
|
||||
import type React from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Button } from "../Button";
|
||||
import { Input } from "../common/Input";
|
||||
import { Loader } from "../Loader";
|
||||
|
||||
/**
|
||||
* Config for the filter currently shown by the shared sheet. Generics are erased
|
||||
* at the FilterButton → provider boundary, so item-typed callbacks use `any`.
|
||||
*/
|
||||
export interface FilterConfig {
|
||||
/** Stable identity — changing it remounts the content with fresh state. */
|
||||
key: string;
|
||||
id: string;
|
||||
queryKey: string;
|
||||
queryFn: (params: any) => Promise<any>;
|
||||
title: string;
|
||||
values: any[];
|
||||
set: (value: any[]) => void;
|
||||
renderItemLabel: (item: any) => React.ReactNode;
|
||||
searchFilter?: (item: any, query: string) => boolean;
|
||||
disableSearch?: boolean;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const LIMIT = 100;
|
||||
|
||||
interface SharedFilterSheetProps {
|
||||
modalRef: React.RefObject<BottomSheetModal | null>;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
config: FilterConfig | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single shared filter sheet — one BottomSheetModal hosted by
|
||||
* FilterSheetProvider for a whole screen; FilterButtons only swap its `config`.
|
||||
* Because only one modal ever exists, rapid taps across buttons can never stack
|
||||
* two sheets, so no guard/timer is needed. The modal shell stays mounted with a
|
||||
* stable ref (present() can run synchronously from the tapping button); the
|
||||
* inner content is keyed by the active filter so its pagination/search reset
|
||||
* cleanly between filters.
|
||||
*/
|
||||
export const SharedFilterSheet: React.FC<SharedFilterSheetProps> = ({
|
||||
modalRef,
|
||||
open,
|
||||
setOpen,
|
||||
config,
|
||||
}) => {
|
||||
const snapPoints = useMemo(() => ["85%"], []);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Opening is imperative (the provider calls present()); this effect only
|
||||
// closes, and never dismisses a modal that was never presented.
|
||||
const wasPresentedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!open && wasPresentedRef.current) {
|
||||
modalRef.current?.dismiss();
|
||||
}
|
||||
}, [open, modalRef]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index >= 0) {
|
||||
wasPresentedRef.current = true;
|
||||
} else if (index === -1) {
|
||||
wasPresentedRef.current = false;
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const requestClose = useCallback(() => setOpen(false), [setOpen]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={modalRef}
|
||||
index={0}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||
>
|
||||
<BottomSheetScrollView style={{ flex: 1 }}>
|
||||
<View
|
||||
className='mt-2 mb-8'
|
||||
style={{
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
}}
|
||||
>
|
||||
{config && (
|
||||
<SharedFilterSheetContent
|
||||
key={config.key}
|
||||
config={config}
|
||||
onRequestClose={requestClose}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
|
||||
interface SharedFilterSheetContentProps {
|
||||
config: FilterConfig;
|
||||
onRequestClose: () => void;
|
||||
}
|
||||
|
||||
const SharedFilterSheetContent: React.FC<SharedFilterSheetContentProps> = ({
|
||||
config,
|
||||
onRequestClose,
|
||||
}) => {
|
||||
const {
|
||||
id,
|
||||
queryKey,
|
||||
queryFn,
|
||||
title,
|
||||
values,
|
||||
set,
|
||||
renderItemLabel,
|
||||
searchFilter,
|
||||
disableSearch = false,
|
||||
multiple = false,
|
||||
} = config;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// The options query lives here (deduped with the FilterButton's own query via
|
||||
// the shared React Query key), so the list stays live after the sheet opens.
|
||||
const { data: _data, isLoading: loading } = useQuery<any[]>({
|
||||
queryKey: ["filters", title, queryKey, id],
|
||||
queryFn,
|
||||
staleTime: 0,
|
||||
enabled: !!id && !!queryFn && !!queryKey,
|
||||
});
|
||||
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
// Filtering on every keystroke blocks the JS thread on large lists; defer the
|
||||
// value so the keystroke render stays cheap and the list update runs after.
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!deferredSearch) return _data;
|
||||
const results = [];
|
||||
for (let i = 0; i < (_data?.length || 0); i++) {
|
||||
if (_data && searchFilter?.(_data[i], deferredSearch)) {
|
||||
results.push(_data[i]);
|
||||
}
|
||||
}
|
||||
return results.slice(0, 100);
|
||||
}, [deferredSearch, _data, searchFilter]);
|
||||
|
||||
// Paginated window derived from the live query result ("load more" grows the
|
||||
// window). No accumulated copy: a background refetch that removes options
|
||||
// can't leave stale rows behind, and no per-item dedupe is needed.
|
||||
const data = useMemo(
|
||||
() => _data?.slice(0, offset + LIMIT) ?? [],
|
||||
[_data, offset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || data.length === 0 || disableSearch) return;
|
||||
if (data.length > 15) {
|
||||
setShowSearch(true);
|
||||
}
|
||||
}, [data, disableSearch]);
|
||||
|
||||
const renderData = useMemo(() => {
|
||||
if (deferredSearch.length > 0 && showSearch) return filteredData;
|
||||
return data;
|
||||
}, [deferredSearch, showSearch, filteredData, data]);
|
||||
|
||||
const renderedRows = useMemo(
|
||||
() =>
|
||||
renderData?.map((item, index) => (
|
||||
<View key={index}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
const isSelected = values.some((value) => isEqual(value, item));
|
||||
if (multiple) {
|
||||
if (!isSelected) set(values.concat(item));
|
||||
else set(values.filter((value) => !isEqual(value, item)));
|
||||
setTimeout(() => onRequestClose(), 250);
|
||||
} else if (!isSelected) {
|
||||
set([item]);
|
||||
setTimeout(() => onRequestClose(), 250);
|
||||
}
|
||||
}}
|
||||
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||
>
|
||||
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
||||
{values.some((i) => isEqual(i, item)) ? (
|
||||
<Ionicons name='radio-button-on' size={24} color='white' />
|
||||
) : (
|
||||
<Ionicons name='radio-button-off' size={24} color='white' />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View
|
||||
style={{ height: StyleSheet.hairlineWidth }}
|
||||
className='h-1 divide-neutral-700'
|
||||
/>
|
||||
</View>
|
||||
)),
|
||||
[renderData, values, multiple, set, renderItemLabel, onRequestClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className='font-bold text-2xl'>{title}</Text>
|
||||
{loading ? (
|
||||
<View className='my-8 flex items-center justify-center'>
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
<Text className='mb-2 text-neutral-500'>
|
||||
{t("search.x_items", { count: _data?.length })}
|
||||
</Text>
|
||||
)}
|
||||
{showSearch && (
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
className='my-2 border-neutral-800 border'
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
returnKeyType='done'
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={{ borderRadius: 20, overflow: "hidden" }}
|
||||
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
||||
>
|
||||
{renderedRows}
|
||||
</View>
|
||||
{data.length < (_data?.length || 0) && (
|
||||
<Button onPress={() => setOffset(offset + LIMIT)}>Load more</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
@@ -33,10 +32,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
disabledByAdmin = false,
|
||||
...viewProps
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const effectiveSubtitle = disabledByAdmin
|
||||
? t("home.settings.disabled_by_admin")
|
||||
: subtitle;
|
||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||
const isDisabled = disabled || disabledByAdmin;
|
||||
if (onPress)
|
||||
return (
|
||||
|
||||
@@ -146,7 +146,7 @@ export const Login: React.FC = () => {
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("login.an_unexpected_error_occured");
|
||||
: t("login.an_unexpected_error_occurred");
|
||||
Alert.alert(t("login.connection_failed"), message);
|
||||
goToQRScreen();
|
||||
} finally {
|
||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("login.an_unexpected_error_occured");
|
||||
: t("login.an_unexpected_error_occurred");
|
||||
Alert.alert(t("login.connection_failed"), message);
|
||||
goToQRScreen();
|
||||
});
|
||||
|
||||
@@ -20,10 +20,7 @@ export const JellyseerrSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
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 { settings, updateSettings } = useSettings();
|
||||
|
||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||
string | undefined
|
||||
@@ -33,25 +30,22 @@ export const JellyseerrSettings = () => {
|
||||
string | 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({
|
||||
mutationFn: async () => {
|
||||
if (!effectiveServerUrl) throw new Error("Missing server url");
|
||||
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
||||
throw new Error("Missing server url");
|
||||
if (!user?.Name)
|
||||
throw new Error("Missing required information for login");
|
||||
const jellyseerrTempApi = new JellyseerrApi(effectiveServerUrl);
|
||||
const jellyseerrTempApi = new JellyseerrApi(
|
||||
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
|
||||
);
|
||||
const testResult = await jellyseerrTempApi.test();
|
||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
setJellyseerrUser(user);
|
||||
updateSettings({ jellyseerrServerUrl: effectiveServerUrl });
|
||||
updateSettings({ jellyseerrServerUrl });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("jellyseerr.failed_to_login"));
|
||||
@@ -121,41 +115,30 @@ export const JellyseerrSettings = () => {
|
||||
</>
|
||||
) : (
|
||||
<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'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||
</Text>
|
||||
<View className='flex flex-col shrink mb-2'>
|
||||
<Text className='text-xs text-gray-600'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||
</Text>
|
||||
<View className='flex flex-col shrink mb-2'>
|
||||
<Text className='text-xs text-gray-600'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||
</Text>
|
||||
</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>
|
||||
)}
|
||||
</View>
|
||||
<Input
|
||||
className='border border-neutral-800 mb-2'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||
)}
|
||||
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
||||
defaultValue={
|
||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||
}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={setjellyseerrServerUrl}
|
||||
editable={!loginToJellyseerrMutation.isPending}
|
||||
/>
|
||||
<View>
|
||||
<Text className='font-bold mb-2'>
|
||||
{t("home.settings.plugins.jellyseerr.password")}
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch } from "react-native";
|
||||
import { Switch, Text, View } from "react-native";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const KefinTweaksSettings = () => {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isEnabled = settings?.useKefinTweaks ?? false;
|
||||
const locked = pluginSettings?.useKefinTweaks?.locked === true;
|
||||
|
||||
return (
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
||||
disabledByAdmin={locked}
|
||||
>
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
disabled={locked}
|
||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
<View className=''>
|
||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||
<Text className='text-xs text-red-600 mb-2'>
|
||||
{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
|
||||
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
|
||||
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
||||
title='Jellyseerr'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
||||
title='Streamystats'
|
||||
title={"Jellyseerr"}
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
|
||||
title='Marlin Search'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
||||
title='Streamystats'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||
title='KefinTweaks'
|
||||
|
||||
@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
successHapticFeedback();
|
||||
Alert.alert(
|
||||
t("home.settings.quick_connect.success"),
|
||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||
t("home.settings.quick_connect.quick_connect_authorized"),
|
||||
);
|
||||
setQuickConnectCode(undefined);
|
||||
bottomSheetModalRef?.current?.close();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -12,6 +12,7 @@ import { ListItem } from "../list/ListItem";
|
||||
export const StorageSettings = () => {
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
const errorHapticFeedback = useHaptic("error");
|
||||
|
||||
@@ -27,16 +28,38 @@ export const StorageSettings = () => {
|
||||
used: (app.total - app.remaining) / app.total,
|
||||
};
|
||||
},
|
||||
// Keep the bar moving while a download is writing to disk.
|
||||
refetchInterval: 10 * 1000,
|
||||
});
|
||||
|
||||
const onDeleteClicked = async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
}
|
||||
const onDeleteClicked = () => {
|
||||
Alert.alert(
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
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) => {
|
||||
|
||||
108
hooks/useFilterReset.ts
Normal file
108
hooks/useFilterReset.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
FilterByPreferenceAtom,
|
||||
filterByAtom,
|
||||
genreFilterAtom,
|
||||
genrePreferenceAtom,
|
||||
SortByOption,
|
||||
SortOrderOption,
|
||||
sortByAtom,
|
||||
sortByPreferenceAtom,
|
||||
sortOrderAtom,
|
||||
sortOrderPreferenceAtom,
|
||||
tagPreferenceAtom,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
yearPreferenceAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
|
||||
/**
|
||||
* Single source of truth for the library filter bar's "reset" action and its
|
||||
* visibility. The mobile ResetFiltersButton and the TV filter header both use
|
||||
* this so they can't drift — sort/order used to be reset on neither path, so
|
||||
* the reset (X) never reflected a changed sort.
|
||||
*
|
||||
* A reset clears the session filters AND the per-library in-memory preferences
|
||||
* (sort, order, filterBy, genres, years, tags); otherwise the saved preference
|
||||
* resurfaces when the library's mount effect re-applies it on the next entry.
|
||||
*/
|
||||
export const useFilterReset = (libraryId: string) => {
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [filterBy, setFilterBy] = useAtom(filterByAtom);
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||
const [, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||
const [, setSortOrderPreference] = useAtom(sortOrderPreferenceAtom);
|
||||
const [, setFilterByPreference] = useAtom(FilterByPreferenceAtom);
|
||||
const [, setGenrePreference] = useAtom(genrePreferenceAtom);
|
||||
const [, setYearPreference] = useAtom(yearPreferenceAtom);
|
||||
const [, setTagPreference] = useAtom(tagPreferenceAtom);
|
||||
|
||||
// SortName / Ascending is the baseline a library opens with (mount-effect
|
||||
// fallback), so any other value counts as an active, resettable sort.
|
||||
const hasActiveFilters =
|
||||
selectedGenres.length > 0 ||
|
||||
selectedYears.length > 0 ||
|
||||
selectedTags.length > 0 ||
|
||||
filterBy.length > 0 ||
|
||||
sortBy[0] !== SortByOption.SortName ||
|
||||
sortOrder[0] !== SortOrderOption.Ascending;
|
||||
|
||||
const resetAllFilters = useCallback(() => {
|
||||
setSelectedGenres([]);
|
||||
setSelectedYears([]);
|
||||
setSelectedTags([]);
|
||||
setFilterBy([]);
|
||||
setSortBy([SortByOption.SortName]);
|
||||
setSortOrder([SortOrderOption.Ascending]);
|
||||
setSortByPreference((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[libraryId];
|
||||
return next;
|
||||
});
|
||||
setSortOrderPreference((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[libraryId];
|
||||
return next;
|
||||
});
|
||||
setFilterByPreference((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[libraryId];
|
||||
return next;
|
||||
});
|
||||
setGenrePreference((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[libraryId];
|
||||
return next;
|
||||
});
|
||||
setYearPreference((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[libraryId];
|
||||
return next;
|
||||
});
|
||||
setTagPreference((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[libraryId];
|
||||
return next;
|
||||
});
|
||||
}, [
|
||||
libraryId,
|
||||
setSelectedGenres,
|
||||
setSelectedYears,
|
||||
setSelectedTags,
|
||||
setFilterBy,
|
||||
setSortBy,
|
||||
setSortOrder,
|
||||
setSortByPreference,
|
||||
setSortOrderPreference,
|
||||
setFilterByPreference,
|
||||
setGenrePreference,
|
||||
setYearPreference,
|
||||
setTagPreference,
|
||||
]);
|
||||
|
||||
return { hasActiveFilters, resetAllFilters };
|
||||
};
|
||||
@@ -96,5 +96,24 @@ export function getDownloadedItemSize(id: string): number {
|
||||
*/
|
||||
export function calculateTotalDownloadedSize(): number {
|
||||
const items = getAllDownloadedItems();
|
||||
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
||||
return items.reduce((sum, item) => {
|
||||
// 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,7 +289,24 @@ export function useDownloadOperations({
|
||||
);
|
||||
|
||||
const appSizeUsage = useCallback(async () => {
|
||||
const totalSize = calculateTotalDownloadedSize();
|
||||
let 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 {
|
||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||
@@ -310,7 +327,7 @@ export function useDownloadOperations({
|
||||
appSize: totalSize,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
}, [processes]);
|
||||
|
||||
return {
|
||||
startBackgroundDownload,
|
||||
|
||||
@@ -386,7 +386,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
default:
|
||||
throw new Error(
|
||||
t(
|
||||
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
|
||||
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
"got_it": "Got it",
|
||||
"connection_failed": "Connection failed",
|
||||
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
||||
"an_unexpected_error_occured": "An unexpected error occurred",
|
||||
"an_unexpected_error_occurred": "An unexpected error occurred",
|
||||
"change_server": "Change server",
|
||||
"invalid_username_or_password": "Invalid username or password",
|
||||
"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_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",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
||||
"an_unexpected_error_occurred_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_description": "Please update Jellyfin to the latest version"
|
||||
},
|
||||
@@ -118,7 +118,6 @@
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "Settings",
|
||||
"disabled_by_admin": "Disabled by admin",
|
||||
"log_out_button": "Log out",
|
||||
"switch_user": {
|
||||
"title": "Switch user",
|
||||
@@ -189,7 +188,7 @@
|
||||
"authorize_button": "Authorize Quick Connect",
|
||||
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
||||
"success": "Success",
|
||||
"quick_connect_autorized": "Quick Connect authorized",
|
||||
"quick_connect_authorized": "Quick Connect authorized",
|
||||
"error": "Error",
|
||||
"invalid_code": "Invalid code",
|
||||
"authorize": "Authorize"
|
||||
@@ -299,7 +298,7 @@
|
||||
"show_custom_menu_links": "Show custom menu links",
|
||||
"show_large_home_carousel": "Show large home carousel (beta)",
|
||||
"hide_libraries": "Hide libraries",
|
||||
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||
"select_libraries_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",
|
||||
"default_quality": "Default quality",
|
||||
"default_playback_speed": "Default playback speed",
|
||||
@@ -371,7 +370,6 @@
|
||||
"toasts": {
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"refresh_failed": "Couldn't refresh settings from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
},
|
||||
"refresh_from_server": "Refresh settings from server"
|
||||
@@ -386,6 +384,8 @@
|
||||
"device_usage": "Device {{availableSpace}}%",
|
||||
"size_used": "{{used}} of {{total}} used",
|
||||
"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_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"clear_music_cache": "Clear music cache",
|
||||
@@ -600,7 +600,7 @@
|
||||
"mpv_player_title": "MPV player",
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"client_error": "Client error",
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from server: {{message}}",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { storage } from "../mmkv";
|
||||
import { useMemo } from "react";
|
||||
import { useSettings } from "./settings";
|
||||
|
||||
export enum SortByOption {
|
||||
@@ -59,32 +58,36 @@ export const sortOptions: {
|
||||
|
||||
export const useFilterOptions = () => {
|
||||
const { settings } = useSettings();
|
||||
// We want to only show the watchlist option if someone has ticked that setting.
|
||||
const filterOptions = settings?.useKefinTweaks
|
||||
? [
|
||||
{
|
||||
key: FilterByOption.IsFavoriteOrLiked,
|
||||
value: "Is Favorite Or Liked",
|
||||
},
|
||||
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
|
||||
{ key: FilterByOption.IsPlayed, value: "Is Played" },
|
||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||
{ key: FilterByOption.Likes, value: "Watchlist" },
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: FilterByOption.IsFavoriteOrLiked,
|
||||
value: "Is Favorite Or Liked",
|
||||
},
|
||||
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
|
||||
{ key: FilterByOption.IsPlayed, value: "Is Played" },
|
||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||
];
|
||||
console.log("filterOptions");
|
||||
console.log(filterOptions);
|
||||
return filterOptions;
|
||||
// Memoized so the array identity stays stable across renders. A fresh array
|
||||
// each render cascades into ListHeaderComponent re-creation and, under heavy
|
||||
// re-rendering (active downloads), trips React's max-update-depth guard.
|
||||
// We only show the watchlist option if someone has ticked that setting.
|
||||
return useMemo(
|
||||
() =>
|
||||
settings?.useKefinTweaks
|
||||
? [
|
||||
{
|
||||
key: FilterByOption.IsFavoriteOrLiked,
|
||||
value: "Is Favorite Or Liked",
|
||||
},
|
||||
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
|
||||
{ key: FilterByOption.IsPlayed, value: "Is Played" },
|
||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||
{ key: FilterByOption.Likes, value: "Watchlist" },
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: FilterByOption.IsFavoriteOrLiked,
|
||||
value: "Is Favorite Or Liked",
|
||||
},
|
||||
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
|
||||
{ key: FilterByOption.IsPlayed, value: "Is Played" },
|
||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||
],
|
||||
[settings?.useKefinTweaks],
|
||||
);
|
||||
};
|
||||
|
||||
export const sortOrderOptions: {
|
||||
@@ -120,57 +123,28 @@ const defaultSortPreference: SortPreference = {};
|
||||
const defaultSortOrderPreference: SortOrderPreference = {};
|
||||
const defaultFilterPreference: FilterPreference = {};
|
||||
|
||||
export const sortByPreferenceAtom = atomWithStorage<SortPreference>(
|
||||
"sortByPreference",
|
||||
defaultSortPreference,
|
||||
{
|
||||
getItem: (key) => {
|
||||
const value = storage.getString(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
storage.set(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: (key) => {
|
||||
storage.remove(key);
|
||||
},
|
||||
},
|
||||
);
|
||||
// Per-library filter memory is intentionally in-memory (NOT atomWithStorage):
|
||||
// each library keeps its own filters for the session, and everything resets
|
||||
// when the app is fully closed.
|
||||
export const sortByPreferenceAtom = atom<SortPreference>(defaultSortPreference);
|
||||
|
||||
export const FilterByPreferenceAtom = atomWithStorage<FilterPreference>(
|
||||
"filterByPreference",
|
||||
export const FilterByPreferenceAtom = atom<FilterPreference>(
|
||||
defaultFilterPreference,
|
||||
{
|
||||
getItem: (key) => {
|
||||
const value = storage.getString(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
storage.set(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: (key) => {
|
||||
storage.remove(key);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const sortOrderPreferenceAtom = atomWithStorage<SortOrderPreference>(
|
||||
"sortOrderPreference",
|
||||
export const sortOrderPreferenceAtom = atom<SortOrderPreference>(
|
||||
defaultSortOrderPreference,
|
||||
{
|
||||
getItem: (key) => {
|
||||
const value = storage.getString(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
storage.set(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: (key) => {
|
||||
storage.remove(key);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Genres / years / tags are multi-select, so each library remembers an array.
|
||||
export interface MultiFilterPreference {
|
||||
[libraryId: string]: string[];
|
||||
}
|
||||
|
||||
export const genrePreferenceAtom = atom<MultiFilterPreference>({});
|
||||
export const yearPreferenceAtom = atom<MultiFilterPreference>({});
|
||||
export const tagPreferenceAtom = atom<MultiFilterPreference>({});
|
||||
|
||||
export const getSortByPreference = (
|
||||
libraryId: string,
|
||||
preferences: SortPreference,
|
||||
@@ -191,3 +165,8 @@ export const getFilterByPreference = (
|
||||
) => {
|
||||
return preferences?.[libraryId] || null;
|
||||
};
|
||||
|
||||
export const getMultiFilterPreference = (
|
||||
libraryId: string,
|
||||
preferences: MultiFilterPreference,
|
||||
) => preferences?.[libraryId] ?? [];
|
||||
|
||||
@@ -449,6 +449,11 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
|
||||
const hasMeaningfulSettingValue = (value: unknown) =>
|
||||
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 = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [_settings, setSettings] = useAtom(settingsAtom);
|
||||
@@ -505,17 +510,7 @@ export const useSettings = () => {
|
||||
if (!_settings) {
|
||||
return;
|
||||
}
|
||||
// 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(
|
||||
const hasChanges = Object.entries(update).some(
|
||||
([key, value]) => _settings[key as keyof Settings] !== value,
|
||||
);
|
||||
|
||||
@@ -524,7 +519,7 @@ export const useSettings = () => {
|
||||
const newSettings = {
|
||||
...defaultValues,
|
||||
..._settings,
|
||||
...sanitizedUpdate,
|
||||
...update,
|
||||
} as Settings;
|
||||
setSettings(newSettings);
|
||||
saveSettings(newSettings);
|
||||
@@ -547,24 +542,13 @@ export const useSettings = () => {
|
||||
// Normalize object-typed settings from plugin (plain primitive → { key, value })
|
||||
value = normalizePluginValue(settingsKey, value);
|
||||
|
||||
// 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];
|
||||
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
|
||||
|
||||
(acc as any)[settingsKey] = locked
|
||||
? value
|
||||
: userDiverged
|
||||
? userValue
|
||||
: hasMeaningfulSettingValue(value)
|
||||
? value
|
||||
: defaultValues[settingsKey];
|
||||
: hasMeaningfulSettingValue(effectiveValue)
|
||||
? effectiveValue
|
||||
: value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
Reference in New Issue
Block a user