Compare commits

..

9 Commits

Author SHA1 Message Date
Fredrik Burmester
4696671bf5 Merge branch 'develop' into feat/refresh-metadata 2025-11-15 09:08:48 +01:00
renovate[bot]
85e5c25206 chore(deps): Update github/codeql-action action to v4.31.3 (#1180)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-14 20:56:45 +01:00
renovate[bot]
3dc84818e8 chore(deps): Update actions/dependency-review-action action to v4.8.2 (#1175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-14 20:56:34 +01:00
renovate[bot]
18102a3045 chore(deps): Update dependency react-native-device-info to v15 (#1182)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-14 20:56:22 +01:00
Fredrik Burmester
2be78a232c fix: linting (#1184) 2025-11-14 19:34:59 +01:00
Fredrik Burmester
30dc3980e3 refactor: better settings (#1178)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-11-14 08:03:00 +01:00
Fredrik Burmester
69d744c86f fix(android): update fill color for refresh button based on loading state 2025-11-08 12:38:18 +01:00
Fredrik Burmester
ad1bd72123 feat: add translation support for metadata refresh messages 2025-11-08 12:37:34 +01:00
Fredrik Burmester
b93c56f300 feat: refresh metadata for item 2025-11-08 10:15:06 +01:00
50 changed files with 1113 additions and 322 deletions

View File

@@ -31,13 +31,13 @@ jobs:
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3

View File

@@ -57,7 +57,7 @@ jobs:
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
with:
fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}

View File

@@ -113,33 +113,144 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name='settings/marlin-search/page'
name='settings/playback-controls/page'
options={{
title: "",
title: t("home.settings.playback_controls.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/jellyseerr/page'
name='settings/audio-subtitles/page'
options={{
title: "",
title: t("home.settings.audio_subtitles.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/hide-libraries/page'
name='settings/appearance/page'
options={{
title: "",
title: t("home.settings.appearance.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/appearance/hide-libraries/page'
options={{
title: t("home.settings.other.hide_libraries"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/plugins/page'
options={{
title: t("home.settings.plugins.plugins_title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/plugins/marlin-search/page'
options={{
title: "Marlin Search",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/plugins/jellyseerr/page'
options={{
title: "Jellyseerr",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/intro/page'
options={{
title: t("home.settings.intro.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
@@ -148,9 +259,16 @@ export default function IndexLayout() {
<Stack.Screen
name='settings/logs/page'
options={{
title: "",
title: t("home.settings.logs.logs_title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),

View File

@@ -8,34 +8,16 @@ import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
import DownloadSettings from "@/components/settings/DownloadSettings";
import { GestureControls } from "@/components/settings/GestureControls";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
import { OtherSettings } from "@/components/settings/OtherSettings";
import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv";
export default function settings() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [_user] = useAtom(userAtom);
const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success");
const onClearLogsClicked = async () => {
clearLogs();
successHapticFeedback();
};
const navigation = useNavigation();
useEffect(() => {
@@ -63,58 +45,51 @@ export default function settings() {
}}
>
<View
className='p-4 flex flex-col gap-y-4'
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<UserInfo />
<View className='mb-4'>
<UserInfo />
</View>
<QuickConnect className='mb-4' />
<MediaProvider>
<MediaToggles className='mb-4' />
<GestureControls className='mb-4' />
<AudioToggles className='mb-4' />
<SubtitleToggles className='mb-4' />
</MediaProvider>
<OtherSettings />
{!Platform.isTV && <DownloadSettings />}
<PluginSettings />
<AppLanguageSelector />
{!Platform.isTV && <ChromecastSettings />}
<ListGroup title={"Intro"}>
<ListItem
onPress={() => {
router.push("/intro/page");
}}
title={t("home.settings.intro.show_intro")}
/>
<ListItem
textColor='red'
onPress={() => {
storage.set("hasShownIntro", false);
}}
title={t("home.settings.intro.reset_intro")}
/>
</ListGroup>
<View className='mb-4'>
<AppLanguageSelector />
</View>
<View className='mb-4'>
<ListGroup title={t("home.settings.logs.logs_title")}>
<ListGroup title={t("home.settings.categories.title")}>
<ListItem
onPress={() => router.push("/settings/playback-controls/page")}
showArrow
title={t("home.settings.playback_controls.title")}
/>
<ListItem
onPress={() => router.push("/settings/audio-subtitles/page")}
showArrow
title={t("home.settings.audio_subtitles.title")}
/>
<ListItem
onPress={() => router.push("/settings/appearance/page")}
showArrow
title={t("home.settings.appearance.title")}
/>
<ListItem
onPress={() => router.push("/settings/plugins/page")}
showArrow
title={t("home.settings.plugins.plugins_title")}
/>
<ListItem
onPress={() => router.push("/settings/intro/page")}
showArrow
title={t("home.settings.intro.title")}
/>
<ListItem
onPress={() => router.push("/settings/logs/page")}
showArrow
title={t("home.settings.logs.logs_title")}
/>
<ListItem
textColor='red'
onPress={onClearLogsClicked}
title={t("home.settings.logs.delete_all_logs")}
/>
</ListGroup>
</View>

View File

@@ -0,0 +1,79 @@
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { ScrollView, Switch, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
});
return response.data.Items || null;
},
});
if (!settings) return null;
if (isLoading)
return (
<View className='mt-4'>
<Loader />
</View>
);
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true}
className='px-4'
>
<ListGroup title={t("home.settings.other.hide_libraries")}>
{data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
<Switch
value={settings.hiddenLibraries?.includes(view.Id!) || false}
onValueChange={(value) => {
updateSettings({
hiddenLibraries: value
? [...(settings.hiddenLibraries || []), view.Id!]
: settings.hiddenLibraries?.filter(
(id) => id !== view.Id,
),
});
}}
/>
</ListItem>
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
</ScrollView>
);
}

View File

@@ -0,0 +1,25 @@
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppearanceSettings } from "@/components/settings/AppearanceSettings";
export default function AppearancePage() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<AppearanceSettings />
<View className='h-24' />
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,29 @@
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { MediaProvider } from "@/components/settings/MediaContext";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
export default function AudioSubtitlesPage() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<MediaProvider>
<AudioToggles className='mb-4' />
<SubtitleToggles className='mb-4' />
</MediaProvider>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,45 @@
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { storage } from "@/utils/mmkv";
export default function IntroPage() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<ListGroup title={t("home.settings.intro.title")}>
<ListItem
onPress={() => {
router.push("/intro/page");
}}
title={t("home.settings.intro.show_intro")}
/>
<ListItem
textColor='red'
onPress={() => {
storage.set("hasShownIntro", false);
}}
title={t("home.settings.intro.reset_intro")}
/>
</ListGroup>
<View className='h-24' />
</View>
</ScrollView>
);
}

View File

@@ -1,16 +0,0 @@
import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const { pluginSettings } = useSettings();
return (
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='p-4'
>
<JellyseerrSettings />
</DisabledSetting>
);
}

View File

@@ -1,9 +1,11 @@
import { File, Paths } from "expo-file-system";
import { useNavigation } from "expo-router";
import * as Sharing from "expo-sharing";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { FilterButton } from "@/components/filters/FilterButton";
import { Loader } from "@/components/Loader";
@@ -32,6 +34,7 @@ export default function Page() {
const _orderId = useId();
const _levelsId = useId();
const insets = useSafeAreaInsets();
const filteredLogs = useMemo(
() =>
@@ -46,18 +49,17 @@ export default function Page() {
// Sharing it as txt while its formatted allows us to share it with many more applications
const share = useCallback(async () => {
const uri = `${FileSystem.documentDirectory}logs.txt`;
const logsFile = new File(Paths.document, "logs.txt");
setLoading(true);
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
.then(() => {
setLoading(false);
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
})
.catch((e) =>
writeErrorLog("Something went wrong attempting to export", e),
)
.finally(() => setLoading(false));
try {
logsFile.write(JSON.stringify(filteredLogs));
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
} catch (e: any) {
writeErrorLog("Something went wrong attempting to export", e);
} finally {
setLoading(false);
}
}, [filteredLogs]);
useEffect(() => {
@@ -66,7 +68,7 @@ export default function Page() {
loading ? (
<Loader />
) : (
<TouchableOpacity onPress={share}>
<TouchableOpacity onPress={share} className='px-2'>
<Text>{t("home.settings.logs.export_logs")}</Text>
</TouchableOpacity>
),
@@ -74,7 +76,12 @@ export default function Page() {
}, [share, loading]);
return (
<>
<View
className='flex-1'
style={{
paddingTop: insets.top + 48,
}}
>
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
<FilterButton
id={orderFilterId}
@@ -156,6 +163,6 @@ export default function Page() {
)}
</View>
</ScrollView>
</>
</View>
);
}

View File

@@ -1,122 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Linking,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
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 { useSettings } from "@/utils/atoms/settings";
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const onSave = (val: string) => {
updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
});
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
};
const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
};
const disabled = useMemo(() => {
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
}, [pluginSettings]);
useEffect(() => {
if (!pluginSettings?.marlinServerUrl?.locked) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)}>
<Text className='text-blue-500'>
{t("home.settings.plugins.marlin_search.save_button")}
</Text>
</TouchableOpacity>
),
});
}
}, [navigation, value]);
if (!settings) return null;
return (
<DisabledSetting disabled={disabled} className='px-4'>
<ListGroup>
<DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem
title={t(
"home.settings.plugins.marlin_search.enable_marlin_search",
)}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup>
<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={settings.searchEngine === "Marlin"}
className='text-white'
placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder",
)}
value={value}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={(text) => setValue(text)}
/>
</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>
</DisabledSetting>
);
}

View File

@@ -0,0 +1,35 @@
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { GestureControls } from "@/components/settings/GestureControls";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
export default function PlaybackControlsPage() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<View className='mb-4'>
<MediaProvider>
<MediaToggles className='mb-4' />
<GestureControls className='mb-4' />
<PlaybackControlsSettings />
</MediaProvider>
</View>
{!Platform.isTV && <ChromecastSettings />}
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,27 @@
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 page() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='px-4'
>
<JellyseerrSettings />
</DisabledSetting>
</ScrollView>
);
}

View File

@@ -0,0 +1,138 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Linking,
ScrollView,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
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 { useSettings } from "@/utils/atoms/settings";
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const onSave = (val: string) => {
updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
});
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
};
const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
};
const disabled = useMemo(() => {
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
}, [pluginSettings]);
useEffect(() => {
if (!pluginSettings?.marlinServerUrl?.locked) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
<Text className='text-blue-500'>
{t("home.settings.plugins.marlin_search.save_button")}
</Text>
</TouchableOpacity>
),
});
}
}, [navigation, value]);
if (!settings) return null;
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<DisabledSetting disabled={disabled} className='px-4'>
<ListGroup>
<DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem
title={t(
"home.settings.plugins.marlin_search.enable_marlin_search",
)}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({
searchEngine: value ? "Marlin" : "Jellyfin",
});
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup>
<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={settings.searchEngine === "Marlin"}
className='text-white'
placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder",
)}
value={value}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={(text) => setValue(text)}
/>
</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>
</DisabledSetting>
</ScrollView>
);
}

View File

@@ -0,0 +1,24 @@
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PluginSettings } from "@/components/settings/PluginSettings";
export default function PluginsPage() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='px-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<PluginSettings />
</View>
</ScrollView>
);
}

View File

@@ -87,14 +87,15 @@ export default function page() {
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
<Text className='opacity-50'>
{t("jellyseerr.born")}{" "}
{new Date(data?.details?.birthday!).toLocaleDateString(
`${locale}-${region}`,
{
year: "numeric",
month: "long",
day: "numeric",
},
)}{" "}
{data?.details?.birthday &&
new Date(data.details.birthday).toLocaleDateString(
`${locale}-${region}`,
{
year: "numeric",
month: "long",
day: "numeric",
},
)}{" "}
| {data?.details?.placeOfBirth}
</Text>
</>

View File

@@ -33,7 +33,6 @@ export default function page() {
<View className='flex flex-1'>
<FlashList
data={channels?.Items}
estimatedItemSize={76}
renderItem={({ item }) => (
<View className='flex flex-row items-center px-4 mb-2'>
<View className='w-22 mr-4 rounded-lg overflow-hidden'>

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.7/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
"files": {
"includes": [
"**/*",

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "streamyfin",
@@ -57,7 +58,7 @@
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
"react-native-device-info": "^14.0.4",
"react-native-device-info": "^15.0.0",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-google-cast": "^4.9.1",
@@ -86,7 +87,7 @@
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@biomejs/biome": "^2.2.4",
"@biomejs/biome": "^2.3.5",
"@react-native-community/cli": "^20.0.0",
"@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.12",
@@ -300,23 +301,23 @@
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@biomejs/biome": ["@biomejs/biome@2.3.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.4", "@biomejs/cli-darwin-x64": "2.3.4", "@biomejs/cli-linux-arm64": "2.3.4", "@biomejs/cli-linux-arm64-musl": "2.3.4", "@biomejs/cli-linux-x64": "2.3.4", "@biomejs/cli-linux-x64-musl": "2.3.4", "@biomejs/cli-win32-arm64": "2.3.4", "@biomejs/cli-win32-x64": "2.3.4" }, "bin": { "biome": "bin/biome" } }, "sha512-TU08LXjBHdy0mEY9APtEtZdNQQijXUDSXR7IK1i45wgoPD5R0muK7s61QcFir6FpOj/RP1+YkPx5QJlycXUU3w=="],
"@biomejs/biome": ["@biomejs/biome@2.3.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.5", "@biomejs/cli-darwin-x64": "2.3.5", "@biomejs/cli-linux-arm64": "2.3.5", "@biomejs/cli-linux-arm64-musl": "2.3.5", "@biomejs/cli-linux-x64": "2.3.5", "@biomejs/cli-linux-x64-musl": "2.3.5", "@biomejs/cli-win32-arm64": "2.3.5", "@biomejs/cli-win32-x64": "2.3.5" }, "bin": { "biome": "bin/biome" } }, "sha512-HvLhNlIlBIbAV77VysRIBEwp55oM/QAjQEin74QQX9Xb259/XP/D5AGGnZMOyF1el4zcvlNYYR3AyTMUV3ILhg=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w40GvlNzLaqmuWYiDU6Ys9FNhJiclngKqcGld3iJIiy2bpJ0Q+8n3haiaC81uTPY/NA0d8Q/I3Z9+ajc14102Q=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fLdTur8cJU33HxHUUsii3GLx/TR0BsfQx8FkeqIiW33cGMtUD56fAtrh+2Fx1uhiCsVZlFh6iLKUU3pniZREQw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-3s7TLVtjJ7ni1xADXsS7x7GMUrLBZXg8SemXc3T0XLslzvqKj/dq1xGeBQ+pOWQzng9MaozfacIHdK2UlJ3jGA=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-qpT8XDqeUlzrOW8zb4k3tjhT7rmvVRumhi2657I2aGcY4B+Ft5fNwDdZGACzn8zj7/K1fdWjgwYE3i2mSZ+vOA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-y7efHyyM2gYmHy/AdWEip+VgTMe9973aP7XYKPzu/j8JxnPHuSUXftzmPhkVw0lfm4ECGbdBdGD6+rLmTgNZaA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-u/pybjTBPGBHB66ku4pK1gj+Dxgx7/+Z0jAriZISPX1ocTO8aHh8x8e7Kb1rB4Ms0nA/SzjtNOVJ4exVavQBCw=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-IruVGQRwMURivWazchiq7gKAqZSFs5so6gi0hJyxk7x6HR+iwZbO2IxNOqyLURBvL06qkIHs7Wffl6Bw30vCbQ=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-eGUG7+hcLgGnMNl1KHVZUYxahYAhC462jF/wQolqu4qso2MSk32Q+QrpN7eN4jAHAg7FUMIo897muIhK4hXhqg=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gKfjWR/6/dfIxPJCw8REdEowiXCkIpl9jycpNVHux8aX2yhWPLjydOshkDL6Y/82PcQJHn95VCj7J+BRcE5o1Q=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-XrIVi9YAW6ye0CGQ+yax0gLfx+BFOtKaNX74n+xHWla6Cl6huUmcKNO7HPx7BiKnJUzrxXY1qYlm7xMvi08X4g=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-mzKFFv/w66e4/jCobFmD3kymCqG+FuWE7sVa4Yjqd9v7qt2UhXo67MSZKY9Ih18V2IwPzRKQPCw6KwdZs6AXSA=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-awVuycTPpVTH/+WDVnEEYSf6nbCBHf/4wB3lquwT7puhNg8R4XvonWNZzUsfHZrCkjkLhFH/vCZK5jHatD9FEg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-5TJ6JfVez+yyupJ/iGUici2wzKf0RrSAxJhghQXtAEsc67OIpdwSKAQboemILrwKfHDi5s6mu7mX+VTCTUydkw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-DlBiMlBZZ9eIq4H7RimDSGsYcOtfOIfZOaI5CqsWiSlbTfqbPVfWtCf92wNzx8GNMbu1s7/g3ZZESr6+GwM/SA=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-FGCijXecmC4IedQ0esdYNlMpx0Jxgf4zceCaMu6fkjWyjgn50ZQtMiqZZQ0Q/77yqPxvtkgZAvt5uGw0gAAjig=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-nUmR8gb6yvrKhtRgzwo/gDimPwnO5a4sCydf8ZS2kHIJhEmSmk+STsusr1LHTuM//wXppBawvSQi2xFXJCdgKQ=="],
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.0.2", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-OrCw8s2NzFxO1TO5W2vyr7HNvh1Yjy00f72D/0BIPtImc0aj5CRrT9nFRE7YP0FWZb0AY5+0QU9jaoph1rBlSg=="],
@@ -1624,7 +1625,7 @@
"react-native-country-flag": ["react-native-country-flag@2.0.2", "", {}, "sha512-5LMWxS79ZQ0Q9ntYgDYzWp794+HcQGXQmzzZNBR1AT7z5HcJHtX7rlk8RHi7RVzfp5gW6plWSZ4dKjRpu/OafQ=="],
"react-native-device-info": ["react-native-device-info@14.1.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-lXFpe6DJmzbQXNLWxlMHP2xuTU5gwrKAvI8dCAZuERhW9eOXSubOQIesk9lIBnsi9pI19GMrcpJEvs4ARPRYmw=="],
"react-native-device-info": ["react-native-device-info@15.0.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-U5waZRXtT3l1SgZpZMlIvMKPTkFZPH8W7Ks6GrJhdH723aUIPxjVer7cRSij1mvQdOAAYFJV/9BDzlC8apG89A=="],
"react-native-edge-to-edge": ["react-native-edge-to-edge@1.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ERegbsq28yoMndn/Uq49i4h6aAhMvTEjOfkFh50yX9H/dMjjCr/Tix/es/9JcPRvC+q7VzCMWfxWDUb6Jrq1OQ=="],

View File

@@ -35,6 +35,7 @@ import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSheet } from "./MediaSourceSheet";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
import { RefreshMetadata } from "./RefreshMetadata";
import { TrackSheet } from "./TrackSheet";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
@@ -115,7 +116,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
<DownloadSingleItem item={item} size='large' />
)}
{user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' />
<>
<PlayInRemoteSessionButton item={item} size='large' />
<RefreshMetadata item={item} />
</>
)}
<PlayedStatus items={[item]} size='large' />
@@ -132,7 +136,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
<DownloadSingleItem item={item} size='large' />
)}
{user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' />
<>
<PlayInRemoteSessionButton item={item} size='large' />
<RefreshMetadata item={item} />
</>
)}
<PlayedStatus items={[item]} size='large' />

View File

@@ -50,7 +50,6 @@ export const PlayButton: React.FC<Props> = ({
selectedOptions,
isOffline,
colors,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();

View File

@@ -0,0 +1,38 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react";
import { Platform, View, type ViewProps } from "react-native";
import { RoundButton } from "@/components/RoundButton";
import { useRefreshMetadata } from "@/hooks/useRefreshMetadata";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const RefreshMetadata: FC<Props> = ({ item, ...props }) => {
const { refreshMetadata, isRefreshing } = useRefreshMetadata(item);
if (Platform.OS === "ios") {
return (
<View {...props}>
<RoundButton
size='large'
icon='reload-outline'
onPress={refreshMetadata}
hapticFeedback={!isRefreshing}
/>
</View>
);
}
return (
<View {...props}>
<RoundButton
size='large'
icon='reload-outline'
onPress={refreshMetadata}
hapticFeedback={!isRefreshing}
fillColor={isRefreshing ? "primary" : undefined}
/>
</View>
);
};

View File

@@ -120,7 +120,6 @@ export function InfiniteHorizontalScroll({
renderItem={({ item, index }) => (
<View className='mr-2'>{renderItem(item, index)}</View>
)}
estimatedItemSize={height}
horizontal
onEndReached={() => {
if (hasNextPage) {

View File

@@ -29,15 +29,15 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { showActionSheetWithOptions } = useActionSheet();
const base64Image = useMemo(() => {
return storage.getString(item?.Id!);
}, []);
return item?.Id ? storage.getString(item.Id) : undefined;
}, [item?.Id]);
/**
* Handles deleting the file with haptic feedback.
*/
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id, item.Type);
deleteFile(item.Id);
}
}, [deleteFile, item.Id]);

View File

@@ -143,7 +143,6 @@ const ParallaxSlideShow = <T,>({
renderItem={({ item, index }) => renderItem(item, index)}
keyExtractor={keyExtractor}
numColumns={3}
estimatedItemSize={214}
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
/>
</View>

View File

@@ -1,16 +1,15 @@
import { FlashList } from "@shopify/flash-list";
import type { ContentStyle } from "@shopify/flash-list/src/FlashListProps";
import { t } from "i18next";
import type React from "react";
import type { PropsWithChildren } from "react";
import { View, type ViewProps } from "react-native";
import { View, type ViewProps, type ViewStyle } from "react-native";
import { Text } from "@/components/common/Text";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
export interface SlideProps {
slide: DiscoverSlider;
contentContainerStyle?: ContentStyle;
contentContainerStyle?: ViewStyle;
}
interface Props<T> extends SlideProps {
@@ -45,7 +44,6 @@ const Slide = <T,>({
}}
showsHorizontalScrollIndicator={false}
keyExtractor={keyExtractor}
estimatedItemSize={250}
data={data}
onEndReachedThreshold={1}
onEndReached={onEndReached}

View File

@@ -34,7 +34,6 @@ export const SearchItemWrapper = <T,>({
}}
showsHorizontalScrollIndicator={false}
keyExtractor={(_, index) => index.toString()}
estimatedItemSize={250}
data={items}
onEndReachedThreshold={1}
onEndReached={onEndReached}

View File

@@ -47,7 +47,6 @@ const JellyseerrSeasonEpisodes: React.FC<{
horizontal
loading={isLoading}
showsHorizontalScrollIndicator={false}
estimatedItemSize={50}
data={seasonWithEpisodes?.episodes}
keyExtractor={(item) => item.id.toString()}
renderItem={(item, index) => (
@@ -284,7 +283,6 @@ const JellyseerrSeasons: React.FC<{
</View>
)}
ItemSeparatorComponent={() => <View className='h-2' />}
estimatedItemSize={250}
renderItem={({ item: season }) => (
<>
<TouchableOpacity

View File

@@ -49,7 +49,6 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
<FlashList
contentContainerStyle={{ paddingLeft: 16 }}
horizontal
estimatedItemSize={172}
showsHorizontalScrollIndicator={false}
data={items}
renderItem={({ item, index }) => (

View File

@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, type ViewProps } from "react-native";
import { TouchableOpacity, type ViewStyle } from "react-native";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
@@ -14,17 +14,20 @@ import {
} from "../common/HorizontalScroll";
import { ItemCardText } from "../ItemCardText";
interface Props extends ViewProps {
interface Props {
item?: BaseItemDto | null;
loading?: boolean;
isOffline?: boolean;
style?: ViewStyle;
containerStyle?: ViewStyle;
}
export const SeasonEpisodesCarousel: React.FC<Props> = ({
item,
loading,
isOffline,
...props
style,
containerStyle,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -90,6 +93,8 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
data={episodes}
extraData={item}
loading={loading || isPending}
style={style}
containerStyle={containerStyle}
renderItem={(_item, _idx) => (
<TouchableOpacity
key={_item.Id}
@@ -104,7 +109,6 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
<ItemCardText item={_item} />
</TouchableOpacity>
)}
{...props}
/>
);
};

View File

@@ -1,3 +1,4 @@
import Ionicons from "@expo/vector-icons/Ionicons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
@@ -50,12 +51,17 @@ export const AppLanguageSelector: React.FC<Props> = () => {
<PlatformDropdown
groups={optionGroups}
trigger={
<View className='bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
<Text>
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-2'>
{APP_LANGUAGES.find(
(l) => l.value === settings?.preferedLanguage,
)?.label || t("home.settings.languages.system")}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.languages.title")}

View File

@@ -0,0 +1,63 @@
import { useRouter } from "expo-router";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Linking, Switch } from "react-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const AppearanceSettings: React.FC = () => {
const router = useRouter();
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
const disabled = useMemo(
() =>
pluginSettings?.showCustomMenuLinks?.locked === true &&
pluginSettings?.hiddenLibraries?.locked === true,
[pluginSettings],
);
if (!settings) return null;
return (
<DisabledSetting disabled={disabled}>
<ListGroup title={t("home.settings.appearance.title")} className=''>
<ListItem
title={t("home.settings.other.show_custom_menu_links")}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onPress={() =>
Linking.openURL(
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links",
)
}
>
<Switch
value={settings.showCustomMenuLinks}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onValueChange={(value) =>
updateSettings({ showCustomMenuLinks: value })
}
/>
</ListItem>
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
<Switch
value={settings.showLargeHomeCarousel}
onValueChange={(value) =>
updateSettings({ showLargeHomeCarousel: value })
}
/>
</ListItem>
<ListItem
onPress={() =>
router.push("/settings/appearance/hide-libraries/page")
}
title={t("home.settings.other.hide_libraries")}
showArrow
/>
</ListGroup>
</DisabledSetting>
);
};

View File

@@ -83,7 +83,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
<PlatformDropdown
groups={optionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{settings?.defaultAudioLanguage?.DisplayName ||
t("home.settings.audio.none")}

View File

@@ -109,7 +109,7 @@ export const OtherSettings: React.FC = () => {
<PlatformDropdown
groups={orientationOptions}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
orientationTranslations[
@@ -152,7 +152,7 @@ export const OtherSettings: React.FC = () => {
keyExtractor={String}
titleExtractor={(item) => t(`home.settings.other.video_players.${VideoPlayer[item]}`)}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<TouchableOpacity className="flex flex-row items-center justify-between py-1.5 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{t(`home.settings.other.video_players.${VideoPlayer[settings.defaultPlayer]}`)}
</Text>
@@ -208,7 +208,7 @@ export const OtherSettings: React.FC = () => {
<PlatformDropdown
groups={bitrateOptions}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{settings.defaultBitrate?.key}
</Text>
@@ -238,7 +238,7 @@ export const OtherSettings: React.FC = () => {
<PlatformDropdown
groups={autoPlayEpisodeOptions}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(settings?.maxAutoPlayEpisodeCount.key)}
</Text>

View File

@@ -0,0 +1,211 @@
import { Ionicons } from "@expo/vector-icons";
import { TFunction } from "i18next";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Switch, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import DisabledSetting from "@/components/settings/DisabledSetting";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const PlaybackControlsSettings: React.FC = () => {
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
const disabled = useMemo(
() =>
pluginSettings?.defaultVideoOrientation?.locked === true &&
pluginSettings?.safeAreaInControlsEnabled?.locked === true &&
pluginSettings?.disableHapticFeedback?.locked === true,
[pluginSettings],
);
const orientations = [
ScreenOrientation.OrientationLock.DEFAULT,
ScreenOrientation.OrientationLock.PORTRAIT_UP,
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
];
const orientationTranslations = useMemo(
() => ({
[ScreenOrientation.OrientationLock.DEFAULT]:
"home.settings.other.orientations.DEFAULT",
[ScreenOrientation.OrientationLock.PORTRAIT_UP]:
"home.settings.other.orientations.PORTRAIT_UP",
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]:
"home.settings.other.orientations.LANDSCAPE_LEFT",
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]:
"home.settings.other.orientations.LANDSCAPE_RIGHT",
}),
[],
);
const orientationOptions = useMemo(
() => [
{
options: orientations.map((orientation) => ({
type: "radio" as const,
label: t(ScreenOrientationEnum[orientation]),
value: String(orientation),
selected: orientation === settings?.defaultVideoOrientation,
onPress: () =>
updateSettings({ defaultVideoOrientation: orientation }),
})),
},
],
[orientations, settings?.defaultVideoOrientation, t, updateSettings],
);
const bitrateOptions = useMemo(
() => [
{
options: BITRATES.map((bitrate) => ({
type: "radio" as const,
label: bitrate.key,
value: bitrate.key,
selected: bitrate.key === settings?.defaultBitrate?.key,
onPress: () => updateSettings({ defaultBitrate: bitrate }),
})),
},
],
[settings?.defaultBitrate?.key, updateSettings],
);
const autoPlayEpisodeOptions = useMemo(
() => [
{
options: AUTOPLAY_EPISODES_COUNT(t).map((item) => ({
type: "radio" as const,
label: item.key,
value: item.key,
selected: item.key === settings?.maxAutoPlayEpisodeCount?.key,
onPress: () => updateSettings({ maxAutoPlayEpisodeCount: item }),
})),
},
],
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
);
if (!settings) return null;
return (
<DisabledSetting disabled={disabled}>
<ListGroup title={t("home.settings.other.other_title")} className=''>
<ListItem
title={t("home.settings.other.video_orientation")}
disabled={pluginSettings?.defaultVideoOrientation?.locked}
>
<PlatformDropdown
groups={orientationOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
orientationTranslations[
settings.defaultVideoOrientation as keyof typeof orientationTranslations
],
) || "Unknown Orientation"}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.orientation")}
/>
</ListItem>
<ListItem
title={t("home.settings.other.safe_area_in_controls")}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
>
<Switch
value={settings.safeAreaInControlsEnabled}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
onValueChange={(value) =>
updateSettings({ safeAreaInControlsEnabled: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.other.default_quality")}
disabled={pluginSettings?.defaultBitrate?.locked}
>
<PlatformDropdown
groups={bitrateOptions}
trigger={
<View className='flex flex-row items-center justify-between pl-3 py-1.5 '>
<Text className='mr-1 text-[#8E8D91]'>
{settings.defaultBitrate?.key}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.default_quality")}
/>
</ListItem>
<ListItem
title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked}
>
<Switch
value={settings.disableHapticFeedback}
disabled={pluginSettings?.disableHapticFeedback?.locked}
onValueChange={(disableHapticFeedback) =>
updateSettings({ disableHapticFeedback })
}
/>
</ListItem>
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
<PlatformDropdown
groups={autoPlayEpisodeOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(settings?.maxAutoPlayEpisodeCount.key)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.max_auto_play_episode_count")}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);
};
const AUTOPLAY_EPISODES_COUNT = (
t: TFunction<"translation", undefined>,
): {
key: string;
value: number;
}[] => [
{ key: t("home.settings.other.disabled"), value: -1 },
{ key: "1", value: 1 },
{ key: "2", value: 2 },
{ key: "3", value: 3 },
{ key: "4", value: 4 },
{ key: "5", value: 5 },
{ key: "6", value: 6 },
{ key: "7", value: 7 },
];

View File

@@ -1,6 +1,5 @@
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -13,23 +12,22 @@ export const PluginSettings = () => {
const { t } = useTranslation();
if (!settings) return null;
return (
<View className='mt-4'>
<ListGroup
title={t("home.settings.plugins.plugins_title")}
className='mb-4'
>
<ListItem
onPress={() => router.push("/settings/jellyseerr/page")}
title={"Jellyseerr"}
showArrow
/>
<ListItem
onPress={() => router.push("/settings/marlin-search/page")}
title='Marlin Search'
showArrow
/>
</ListGroup>
</View>
<ListGroup
title={t("home.settings.plugins.plugins_title")}
className='mb-4'
>
<ListItem
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
title={"Jellyseerr"}
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/marlin-search/page")}
title='Marlin Search'
showArrow
/>
</ListGroup>
);
};

View File

@@ -187,7 +187,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
<PlatformDropdown
groups={subtitleLanguageOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{settings?.defaultSubtitleLanguage?.DisplayName ||
t("home.settings.subtitles.none")}
@@ -210,7 +210,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
<PlatformDropdown
groups={subtitleModeOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(subtitleModeKeys[settings?.subtitleMode]) ||
t("home.settings.subtitles.loading")}
@@ -256,7 +256,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
<PlatformDropdown
groups={textColorOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
@@ -276,7 +276,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
<PlatformDropdown
groups={backgroundColorOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
@@ -296,7 +296,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
<PlatformDropdown
groups={outlineColorOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
@@ -316,7 +316,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
<PlatformDropdown
groups={outlineThicknessOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
@@ -336,7 +336,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
<PlatformDropdown
groups={backgroundOpacityOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}</Text>
<Ionicons
name='chevron-expand-sharp'
@@ -352,7 +352,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
<PlatformDropdown
groups={outlineOpacityOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}</Text>
<Ionicons
name='chevron-expand-sharp'

View File

@@ -271,7 +271,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
</View>
)}
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
estimatedItemSize={200}
showsHorizontalScrollIndicator={false}
/>
)}

View File

@@ -89,10 +89,10 @@ const SliderScrubber: React.FC<SliderScrubberProps> = ({
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
width: 150 * trickplayInfo.data.TileWidth,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
(150 / trickplayInfo.aspectRatio) *
trickplayInfo.data.TileHeight,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },

View File

@@ -63,10 +63,10 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
<Image
cachePolicy={"memory-disk"}
style={{
width: tileWidth * trickplayInfo?.data.TileWidth!,
width: tileWidth * (trickplayInfo.data.TileWidth ?? 1),
height:
(tileWidth / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
(tileWidth / (trickplayInfo.aspectRatio ?? 1)) *
(trickplayInfo.data.TileHeight ?? 1),
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },

View File

@@ -0,0 +1,42 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getItemRefreshApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { toast } from "sonner-native";
import { apiAtom } from "@/providers/JellyfinProvider";
export const useRefreshMetadata = (item: BaseItemDto) => {
const queryClient = useQueryClient();
const [api] = useAtom(apiAtom);
const { t } = useTranslation();
const type = "item";
const refreshMetadataMutation = useMutation({
mutationFn: async () => {
if (api && item.Id) {
await getItemRefreshApi(api).refreshItem({
itemId: item.Id,
});
}
},
onSuccess: () => {
toast.success(t("metadata.refresh_triggered"));
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
},
onError: (error) => {
console.error("Failed to refresh metadata:", error);
toast.error(t("metadata.refresh_failed"));
},
});
const refreshMetadata = () => {
refreshMetadataMutation.mutate();
};
return {
refreshMetadata,
isRefreshing: refreshMetadataMutation.isPending,
refreshMetadataMutation,
};
};

View File

@@ -31,7 +31,9 @@ export interface ActiveDownload {
export interface BackgroundDownloaderModuleType {
startDownload(url: string, destinationPath?: string): Promise<number>;
enqueueDownload(url: string, destinationPath?: string): Promise<number>;
cancelDownload(taskId: number): void;
cancelQueuedDownload(url: string): void;
cancelAllDownloads(): void;
getActiveDownloads(): Promise<ActiveDownload[]>;
addListener(

View File

@@ -75,7 +75,7 @@
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
"react-native-device-info": "^14.0.4",
"react-native-device-info": "^15.0.0",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-google-cast": "^4.9.1",
@@ -104,7 +104,7 @@
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@biomejs/biome": "^2.2.4",
"@biomejs/biome": "^2.3.5",
"@react-native-community/cli": "^20.0.0",
"@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.12",

View File

@@ -8,6 +8,7 @@ import {
getAllDownloadedItems,
getDownloadedItemById,
getDownloadsDatabase,
updateDownloadedItem,
} from "./Downloads/database";
import { getDownloadedItemSize } from "./Downloads/fileOperations";
import { useDownloadEventHandlers } from "./Downloads/hooks/useDownloadEventHandlers";
@@ -29,7 +30,7 @@ function useDownloadProvider() {
const successHapticFeedback = useHaptic("success");
// Track task ID to process ID mapping
const taskMapRef = useRef<Map<number, string>>(new Map());
const taskMapRef = useRef<Map<number | string, string>>(new Map());
// Reactive downloaded items that updates when refreshKey changes
const downloadedItems = useMemo(() => {
@@ -130,13 +131,13 @@ function useDownloadProvider() {
cancelDownload,
getDownloadedItemSize,
getDownloadedItemById,
updateDownloadedItem,
triggerRefresh,
APP_CACHE_DOWNLOAD_DIRECTORY: APP_CACHE_DOWNLOAD_DIRECTORY.uri,
appSizeUsage,
// Deprecated/not implemented in simple version
startDownload: async () => {},
cleanCacheDirectory: async () => {},
updateDownloadedItem: () => {},
dumpDownloadDiagnostics: async () => "",
};
}
@@ -161,9 +162,9 @@ export function useDownload() {
startDownload: async () => {},
getDownloadedItemSize: () => 0,
getDownloadedItemById: () => undefined,
updateDownloadedItem: () => {},
APP_CACHE_DOWNLOAD_DIRECTORY: "",
cleanCacheDirectory: async () => {},
updateDownloadedItem: () => {},
appSizeUsage: async () => ({ total: 0, remaining: 0, appSize: 0 }),
dumpDownloadDiagnostics: async () => "",
};

View File

@@ -185,10 +185,16 @@ export async function fetchSegments(
}> {
try {
const segments = await fetchAndParseSegments(itemId, api);
return segments;
return {
introSegments: segments.introSegments,
creditSegments: segments.creditSegments,
};
} catch (error) {
console.error(`[SEGMENTS] Failed to fetch segments:`, error);
return {};
return {
introSegments: undefined,
creditSegments: undefined,
};
}
}
@@ -222,7 +228,12 @@ export async function downloadAdditionalAssets(params: {
mediaSource.TranscodingUrl
? Promise.resolve(mediaSource)
: downloadSubtitles(mediaSource, item, api.basePath || ""),
item.Id ? fetchSegments(item.Id, api) : Promise.resolve({}),
item.Id
? fetchSegments(item.Id, api)
: Promise.resolve({
introSegments: undefined,
creditSegments: undefined,
}),
// Cover image downloads (run but don't wait for results)
downloadCoverImage(item, api, saveImageFn).catch((err) => {
console.error("[COVER] Error downloading cover:", err);

View File

@@ -181,6 +181,41 @@ export function removeDownloadedItem(id: string): DownloadedItem | undefined {
return itemToDelete;
}
/**
* Update a downloaded item in the database
*/
export function updateDownloadedItem(
_id: string,
updatedItem: DownloadedItem,
): void {
const db = getDownloadsDatabase();
const baseItem = updatedItem.item;
if (baseItem.Type === "Movie" && baseItem.Id) {
db.movies[baseItem.Id] = updatedItem;
} else if (
baseItem.Type === "Episode" &&
baseItem.SeriesId &&
baseItem.ParentIndexNumber !== undefined &&
baseItem.ParentIndexNumber !== null &&
baseItem.IndexNumber !== undefined &&
baseItem.IndexNumber !== null
) {
const seriesId = baseItem.SeriesId;
const seasonNumber = baseItem.ParentIndexNumber;
const episodeNumber = baseItem.IndexNumber;
if (db.series[seriesId]?.seasons[seasonNumber]?.episodes[episodeNumber]) {
db.series[seriesId].seasons[seasonNumber].episodes[episodeNumber] =
updatedItem;
}
} else if (baseItem.Id && db.other?.[baseItem.Id]) {
db.other[baseItem.Id] = updatedItem;
}
saveDownloadsDatabase(db);
}
/**
* Clear all downloaded items from the database
*/

View File

@@ -24,7 +24,7 @@ import {
} from "./useDownloadSpeedCalculator";
interface UseDownloadEventHandlersProps {
taskMapRef: MutableRefObject<Map<number, string>>;
taskMapRef: MutableRefObject<Map<number | string, string>>;
processes: JobStatus[];
updateProcess: (
processId: string,
@@ -59,7 +59,8 @@ export function useDownloadEventHandlers({
// If no mapping exists, find by URL (for queued downloads)
if (!processId && event.url) {
// Check if we have a URL mapping (queued download)
processId = taskMapRef.current.get(event.url);
const urlKey = event.url;
processId = taskMapRef.current.get(urlKey);
if (!processId) {
// Fallback: search by matching URL in processes
@@ -74,7 +75,7 @@ export function useDownloadEventHandlers({
if (processId) {
// Create taskId mapping and remove URL mapping
taskMapRef.current.set(event.taskId, processId);
taskMapRef.current.delete(event.url);
taskMapRef.current.delete(urlKey);
console.log(
`[DPL] Mapped queued download: taskId=${event.taskId} to processId=${processId.slice(0, 8)}...`,
);

View File

@@ -27,7 +27,7 @@ import type { JobStatus } from "../types";
import { generateFilename, uriToFilePath } from "../utils";
interface UseDownloadOperationsProps {
taskMapRef: MutableRefObject<Map<number, string>>;
taskMapRef: MutableRefObject<Map<number | string, string>>;
processes: JobStatus[];
setProcesses: (updater: (prev: JobStatus[]) => JobStatus[]) => void;
removeProcess: (id: string) => void;
@@ -169,7 +169,7 @@ export function useDownloadOperations({
if (typeof key === "number") {
taskId = key;
} else {
downloadUrl = key;
downloadUrl = key as string;
}
}
});

View File

@@ -94,9 +94,9 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
console.log(`${data?.url?.slice(0, 100)}...${data?.url?.slice(-50)}`);
_setPlaySettings(newSettings);
setPlayUrl(data?.url!);
setPlaySessionId(data?.sessionId!);
setMediaSource(data?.mediaSource!);
if (data?.url) setPlayUrl(data.url);
if (data?.sessionId) setPlaySessionId(data.sessionId);
if (data?.mediaSource) setMediaSource(data.mediaSource);
return data;
} catch (error) {

View File

@@ -69,6 +69,18 @@
"settings": {
"settings_title": "Settings",
"log_out_button": "Log Out",
"categories": {
"title": "Categories"
},
"playback_controls": {
"title": "Playback & Controls"
},
"audio_subtitles": {
"title": "Audio & Subtitles"
},
"appearance": {
"title": "Appearance"
},
"user_info": {
"user_info_title": "User Info",
"user": "User",
@@ -236,6 +248,7 @@
"delete_all_downloaded_files": "Delete All Downloaded Files"
},
"intro": {
"title": "Intro",
"show_intro": "Show Intro",
"reset_intro": "Reset Intro"
},
@@ -515,5 +528,9 @@
"library": "Library",
"custom_links": "Custom Links",
"favorites": "Favorites"
},
"metadata": {
"refresh_triggered": "Metadata refresh triggered",
"refresh_failed": "Failed to refresh metadata"
}
}