Compare commits

..

2 Commits

Author SHA1 Message Date
Fredrik Burmester
16bb1b8717 wip 2025-11-16 13:37:27 +01:00
Fredrik Burmester
259306df52 feat: choose download location (sd card) 2025-11-16 13:37:27 +01:00
25 changed files with 769 additions and 577 deletions

View File

@@ -1,6 +1,5 @@
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ControlsSettings } from "@/components/settings/ControlsSettings";
import { GestureControls } from "@/components/settings/GestureControls";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
@@ -26,7 +25,6 @@ export default function PlaybackControlsPage() {
<MediaProvider>
<MediaToggles className='mb-4' />
<GestureControls className='mb-4' />
<ControlsSettings className='mb-4' />
<PlaybackControlsSettings />
</MediaProvider>
</View>

View File

@@ -27,9 +27,6 @@ const Page: React.FC = () => {
ItemFields.MediaStreams,
]);
// preload media sources in background
useItemQuery(id, false, undefined, []);
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
return {

View File

@@ -14,7 +14,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
@@ -34,16 +33,8 @@ import {
type IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import {
MediaRequestStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
MovieResult,
@@ -67,7 +58,7 @@ const Page: React.FC = () => {
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const navigation = useNavigation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
@@ -100,46 +91,6 @@ const Page: React.FC = () => {
const [canRequest, hasAdvancedRequestPermission] =
useJellyseerrCanRequest(details);
const canManageRequests = useMemo(() => {
if (!jellyseerrUser) return false;
return hasPermission(
Permission.MANAGE_REQUESTS,
jellyseerrUser.permissions,
);
}, [jellyseerrUser]);
const pendingRequest = useMemo(() => {
return details?.mediaInfo?.requests?.find(
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
);
}, [details]);
const handleApproveRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.approveRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_approved"));
refetch();
} catch (error) {
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
console.error("Failed to approve request:", error);
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const handleDeclineRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.declineRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_declined"));
refetch();
} catch (error) {
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
console.error("Failed to decline request:", error);
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
@@ -383,60 +334,6 @@ const Page: React.FC = () => {
</View>
)
)}
{canManageRequests && pendingRequest && (
<View className='flex flex-col space-y-2 mt-4'>
<View className='flex flex-row items-center space-x-2'>
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
<Text className='text-sm text-neutral-400'>
{t("jellyseerr.requested_by", {
user:
pendingRequest.requestedBy?.displayName ||
pendingRequest.requestedBy?.username ||
pendingRequest.requestedBy?.jellyfinUsername ||
t("jellyseerr.unknown_user"),
})}
</Text>
</View>
<View className='flex flex-row space-x-2'>
<Button
className='flex-1 bg-green-600/50 border-green-400 ring-green-400 text-green-100'
color='transparent'
onPress={handleApproveRequest}
iconLeft={
<Ionicons
name='checkmark-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
</Button>
<Button
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
color='transparent'
onPress={handleDeclineRequest}
iconLeft={
<Ionicons
name='close-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
</Button>
</View>
</View>
)}
<OverviewText text={result.overview} className='mt-4' />
</View>

View File

@@ -2,6 +2,7 @@ import type React from "react";
import {
type PropsWithChildren,
type ReactNode,
useMemo,
useRef,
useState,
} from "react";
@@ -17,58 +18,6 @@ import {
import { useHaptic } from "@/hooks/useHaptic";
import { Loader } from "./Loader";
const getColorClasses = (
color: "purple" | "red" | "black" | "transparent" | "white",
variant: "solid" | "border",
focused: boolean,
): string => {
if (variant === "border") {
switch (color) {
case "purple":
return focused
? "bg-transparent border-2 border-purple-400"
: "bg-transparent border-2 border-purple-600";
case "red":
return focused
? "bg-transparent border-2 border-red-400"
: "bg-transparent border-2 border-red-600";
case "black":
return focused
? "bg-transparent border-2 border-neutral-700"
: "bg-transparent border-2 border-neutral-900";
case "white":
return focused
? "bg-transparent border-2 border-gray-100"
: "bg-transparent border-2 border-white";
case "transparent":
return focused
? "bg-transparent border-2 border-gray-400"
: "bg-transparent border-2 border-gray-600";
default:
return "";
}
} else {
switch (color) {
case "purple":
return focused
? "bg-purple-500 border-2 border-white"
: "bg-purple-600 border border-purple-700";
case "red":
return "bg-red-600";
case "black":
return "bg-neutral-900";
case "white":
return focused
? "bg-gray-100 border-2 border-gray-300"
: "bg-white border border-gray-200";
case "transparent":
return "bg-transparent";
default:
return "";
}
}
};
export interface ButtonProps
extends React.ComponentProps<typeof TouchableOpacity> {
onPress?: () => void;
@@ -77,8 +26,7 @@ export interface ButtonProps
disabled?: boolean;
children?: string | ReactNode;
loading?: boolean;
color?: "purple" | "red" | "black" | "transparent" | "white";
variant?: "solid" | "border";
color?: "purple" | "red" | "black" | "transparent";
iconRight?: ReactNode;
iconLeft?: ReactNode;
justify?: "center" | "between";
@@ -91,7 +39,6 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
disabled = false,
loading = false,
color = "purple",
variant = "solid",
iconRight,
iconLeft,
children,
@@ -109,13 +56,23 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
useNativeDriver: true,
}).start();
const colorClasses = getColorClasses(color, variant, focused);
const colorClasses = useMemo(() => {
switch (color) {
case "purple":
return focused
? "bg-purple-500 border-2 border-white"
: "bg-purple-600 border border-purple-700";
case "red":
return "bg-red-600";
case "black":
return "bg-neutral-900";
case "transparent":
return "bg-transparent";
}
}, [color, focused]);
const lightHapticFeedback = useHaptic("light");
const textColorClass =
color === "white" && variant === "solid" ? "text-black" : "text-white";
return Platform.isTV ? (
<Pressable
className='w-full'
@@ -141,12 +98,10 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
>
<View
className={`rounded-2xl py-5 items-center justify-center
${colorClasses}
${focused ? "bg-purple-500 border-2 border-white" : "bg-purple-600 border border-purple-700"}
${className}`}
>
<Text className={`${textColorClass} text-xl font-bold`}>
{children}
</Text>
<Text className='text-white text-xl font-bold'>{children}</Text>
</View>
</Animated.View>
</Pressable>
@@ -180,7 +135,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
{iconLeft ? iconLeft : <View className='w-4' />}
<Text
className={`
${textColorClass} font-bold text-base
text-white font-bold text-base
${disabled ? "text-gray-300" : ""}
${textClassName}
${iconRight ? "mr-2" : ""}

View File

@@ -6,6 +6,7 @@ import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { type Bitrate } from "@/components/BitrateSelector";
@@ -23,6 +24,7 @@ import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useItemQuery } from "@/hooks/useItemQuery";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -30,7 +32,6 @@ import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites";
import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
@@ -56,6 +57,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const itemColors = useImageColorsReturn({ item });
@@ -66,6 +68,9 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
SelectedOptions | undefined
>(undefined);
// preload media sources
useItemQuery(item.Id, false, undefined, []);
const {
defaultAudioIndex,
defaultBitrate,
@@ -226,12 +231,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
/>
)}
{!isOffline &&
selectedOptions.mediaSource?.MediaStreams &&
selectedOptions.mediaSource.MediaStreams.length > 0 && (
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
)}
<OverviewText text={item.Overview} className='px-4 mb-4' />
{item.Type !== "Program" && (

View File

@@ -1,12 +1,11 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
PlayServicesState,
@@ -25,8 +24,6 @@ import Animated, {
} from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
@@ -36,8 +33,6 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Button } from "./Button";
import { Text } from "./common/Text";
import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
@@ -60,7 +55,6 @@ export const PlayButton: React.FC<Props> = ({
const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus();
const { t } = useTranslation();
const { showModal, hideModal } = useGlobalModal();
const [globalColorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
@@ -90,9 +84,12 @@ export const PlayButton: React.FC<Props> = ({
[router, isOffline],
);
const handleNormalPlayFlow = useCallback(async () => {
const onPress = useCallback(async () => {
console.log("onPress");
if (!item) return;
lightHapticFeedback();
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
@@ -274,118 +271,6 @@ export const PlayButton: React.FC<Props> = ({
showActionSheetWithOptions,
mediaStatus,
selectedOptions,
goToPlayer,
isOffline,
t,
]);
const onPress = useCallback(async () => {
console.log("onPress");
if (!item) return;
lightHapticFeedback();
// Check if item is downloaded
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
if (downloadedItem) {
if (Platform.OS === "android") {
// Show bottom sheet for Android
showModal(
<BottomSheetView>
<View className='px-4 mt-4 mb-12'>
<View className='pb-6'>
<Text className='text-2xl font-bold mb-2'>
{t("player.downloaded_file_title")}
</Text>
<Text className='opacity-70 text-base'>
{t("player.downloaded_file_message")}
</Text>
</View>
<View className='space-y-3'>
<Button
onPress={() => {
hideModal();
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}}
color='purple'
>
{Platform.OS === "android"
? "Play downloaded file"
: t("player.downloaded_file_yes")}
</Button>
<Button
onPress={() => {
hideModal();
handleNormalPlayFlow();
}}
color='white'
variant='border'
>
{Platform.OS === "android"
? "Stream file"
: t("player.downloaded_file_no")}
</Button>
</View>
</View>
</BottomSheetView>,
{
snapPoints: ["35%"],
enablePanDownToClose: true,
},
);
} else {
// Show alert for iOS
Alert.alert(
t("player.downloaded_file_title"),
t("player.downloaded_file_message"),
[
{
text: t("player.downloaded_file_yes"),
onPress: () => {
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
},
isPreferred: true,
},
{
text: t("player.downloaded_file_no"),
onPress: () => {
handleNormalPlayFlow();
},
},
{
text: t("player.downloaded_file_cancel"),
style: "cancel",
},
],
);
}
return;
}
// If not downloaded, proceed with normal flow
handleNormalPlayFlow();
}, [
item,
lightHapticFeedback,
handleNormalPlayFlow,
goToPlayer,
t,
showModal,
hideModal,
effectiveColors,
]);
const derivedTargetWidth = useDerivedValue(() => {

View File

@@ -4,7 +4,6 @@ import {
type QueryKey,
useInfiniteQuery,
} from "@tanstack/react-query";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
@@ -65,11 +64,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
// Flatten all pages into a single array
const allItems = data?.pages.flat() || [];
const snapOffsets = useMemo(() => {
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
return allItems.map((_, index) => index * itemWidth);
}, [allItems, orientation]);
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
if (disabled || !title) return null;
@@ -132,8 +126,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
showsHorizontalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
snapToOffsets={snapOffsets}
decelerationRate='fast'
>
<View className='px-4 flex flex-row'>
{allItems.map((item) => (

View File

@@ -9,7 +9,7 @@ import {
useQuery,
} from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { View, type ViewProps } from "react-native";
import { useInView } from "@/hooks/useInView";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -67,12 +67,6 @@ export const MediaListSection: React.FC<Props> = ({
[api, user?.Id, collection?.Id],
);
const snapOffsets = useMemo(() => {
const itemWidth = 120; // w-28 (112px) + mr-2 (8px)
// Generate offsets for a reasonable number of items
return Array.from({ length: 50 }, (_, index) => index * itemWidth);
}, []);
if (!collection) return null;
return (
@@ -98,8 +92,6 @@ export const MediaListSection: React.FC<Props> = ({
)}
queryFn={fetchItems}
queryKey={["media-list", collection.Id!]}
snapToOffsets={snapOffsets}
decelerationRate='fast'
/>
</View>
);

View File

@@ -40,7 +40,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
const scrollRef = useRef<HorizontalScrollRef>(null);
const scrollToIndex = (index: number) => {
scrollRef.current?.scrollToIndex(index, -16);
scrollRef.current?.scrollToIndex(index, 16);
};
const seasonId = useMemo(() => {
@@ -87,11 +87,6 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}
}, [episodes, item]);
const snapOffsets = useMemo(() => {
const itemWidth = 184; // w-44 (176px) + mr-2 (8px)
return episodes?.map((_, index) => index * itemWidth) || [];
}, [episodes]);
return (
<HorizontalScroll
ref={scrollRef}
@@ -114,8 +109,6 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
<ItemCardText item={_item} />
</TouchableOpacity>
)}
snapToOffsets={snapOffsets}
decelerationRate='fast'
/>
);
};

View File

@@ -1,76 +0,0 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { ViewProps } from "react-native";
import { Switch } from "react-native";
import { ListItem } from "@/components/list/ListItem";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import DisabledSetting from "./DisabledSetting";
interface Props extends ViewProps {}
export const ControlsSettings: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation();
const { settings, updateSettings, pluginSettings } = useSettings();
const disabled = useMemo(
() =>
pluginSettings?.showVolumeSlider?.locked === true &&
pluginSettings?.showBrightnessSlider?.locked === true &&
pluginSettings?.showSeekButtons?.locked === true,
[pluginSettings],
);
if (!settings) return null;
return (
<DisabledSetting disabled={disabled} {...props}>
<ListGroup title={t("home.settings.controls.controls_title")}>
<ListItem
title={t("home.settings.controls.show_volume_slider")}
subtitle={t("home.settings.controls.show_volume_slider_description")}
disabled={pluginSettings?.showVolumeSlider?.locked}
>
<Switch
value={settings.showVolumeSlider}
disabled={pluginSettings?.showVolumeSlider?.locked}
onValueChange={(showVolumeSlider) =>
updateSettings({ showVolumeSlider })
}
/>
</ListItem>
<ListItem
title={t("home.settings.controls.show_brightness_slider")}
subtitle={t(
"home.settings.controls.show_brightness_slider_description",
)}
disabled={pluginSettings?.showBrightnessSlider?.locked}
>
<Switch
value={settings.showBrightnessSlider}
disabled={pluginSettings?.showBrightnessSlider?.locked}
onValueChange={(showBrightnessSlider) =>
updateSettings({ showBrightnessSlider })
}
/>
</ListItem>
<ListItem
title={t("home.settings.controls.show_seek_buttons")}
subtitle={t("home.settings.controls.show_seek_buttons_description")}
disabled={pluginSettings?.showSeekButtons?.locked}
>
<Switch
value={settings.showSeekButtons}
disabled={pluginSettings?.showSeekButtons?.locked}
onValueChange={(showSeekButtons) =>
updateSettings({ showSeekButtons })
}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);
};

View File

@@ -0,0 +1,212 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { forwardRef, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
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 type { StorageLocation } from "@/modules";
import { useSettings } from "@/utils/atoms/settings";
import {
clearStorageLocationsCache,
getAvailableStorageLocations,
} from "@/utils/storage";
interface StorageLocationPickerProps {
onClose: () => void;
}
export const StorageLocationPicker = forwardRef<
BottomSheetModal,
StorageLocationPickerProps
>(({ onClose }, ref) => {
const { t } = useTranslation();
const { settings, updateSettings } = useSettings();
const insets = useSafeAreaInsets();
const [selectedId, setSelectedId] = useState<string | undefined>(
settings.downloadStorageLocation || "internal",
);
const { data: locations, isLoading } = useQuery({
queryKey: ["storageLocations"],
queryFn: getAvailableStorageLocations,
enabled: Platform.OS === "android",
});
const handleSelect = (location: StorageLocation) => {
setSelectedId(location.id);
};
const handleConfirm = () => {
updateSettings({ downloadStorageLocation: selectedId });
clearStorageLocationsCache(); // Clear cache so next download uses new location
toast.success(
t("settings.storage.storage_location_updated", {
defaultValue: "Storage location updated",
}),
);
onClose();
};
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
if (Platform.OS !== "android") {
return null;
}
return (
<BottomSheetModal
ref={ref}
enableDynamicSizing
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
enablePanDownToClose
enableDismissOnClose
>
<BottomSheetScrollView
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
<View className='px-4 pt-2'>
<Text className='text-lg font-semibold mb-1'>
{t("settings.storage.select_storage_location", {
defaultValue: "Select Storage Location",
})}
</Text>
<Text className='text-sm text-neutral-500 mb-4'>
{t("settings.storage.existing_downloads_note", {
defaultValue:
"Existing downloads will remain in their current location",
})}
</Text>
{isLoading ? (
<View className='items-center justify-center py-8'>
<ActivityIndicator size='large' />
<Text className='mt-4 text-neutral-500'>
{t("settings.storage.loading_storage", {
defaultValue: "Loading storage options...",
})}
</Text>
</View>
) : !locations || locations.length === 0 ? (
<View className='items-center justify-center py-8'>
<Text className='text-neutral-500'>
{t("settings.storage.no_storage_found", {
defaultValue: "No storage locations found",
})}
</Text>
</View>
) : (
<>
{locations.map((location) => {
const isSelected = selectedId === location.id;
const freeSpaceGB = (location.freeSpace / 1024 ** 3).toFixed(2);
const totalSpaceGB = (location.totalSpace / 1024 ** 3).toFixed(
2,
);
const usedPercent = (
((location.totalSpace - location.freeSpace) /
location.totalSpace) *
100
).toFixed(0);
return (
<TouchableOpacity
key={location.id}
onPress={() => handleSelect(location)}
className={`p-4 mb-2 rounded-lg ${
isSelected
? "bg-purple-600/20 border border-purple-600"
: "bg-neutral-800"
}`}
>
<View className='flex-row items-center justify-between'>
<View className='flex-1'>
<View className='flex-row items-center'>
<Text className='text-base font-semibold'>
{location.label}
</Text>
{location.type === "external" && (
<View className='ml-2 px-2 py-0.5 bg-blue-600/30 rounded'>
<Text className='text-xs text-blue-400'>
{t("settings.storage.removable", {
defaultValue: "Removable",
})}
</Text>
</View>
)}
</View>
<Text className='text-sm text-neutral-500 mt-1'>
{t("settings.storage.space_info", {
defaultValue:
"{{free}} GB free of {{total}} GB ({{used}}% used)",
free: freeSpaceGB,
total: totalSpaceGB,
used: usedPercent,
})}
</Text>
</View>
{isSelected && (
<View className='w-6 h-6 rounded-full bg-purple-600 items-center justify-center ml-2'>
<Text className='text-white text-xs'></Text>
</View>
)}
</View>
</TouchableOpacity>
);
})}
<View className='flex-row gap-x-2 py-4'>
<TouchableOpacity
onPress={onClose}
className='flex-1 py-3 rounded-lg bg-neutral-800 items-center'
>
<Text className='text-white font-semibold'>
{t("common.cancel", { defaultValue: "Cancel" })}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleConfirm}
className='flex-1 py-3 rounded-lg bg-purple-600 items-center'
disabled={!selectedId}
>
<Text className='text-white font-semibold'>
{t("common.confirm", { defaultValue: "Confirm" })}
</Text>
</TouchableOpacity>
</View>
</>
)}
</View>
</BottomSheetScrollView>
</BottomSheetModal>
);
});

View File

@@ -1,4 +1,6 @@
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { toast } from "sonner-native";
@@ -6,14 +8,19 @@ import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStorageLabel } from "@/utils/storage";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { StorageLocationPicker } from "./StorageLocationPicker";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
const { settings } = useSettings();
const { t } = useTranslation();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { data: size } = useQuery({
queryKey: ["appSize"],
@@ -29,6 +36,12 @@ export const StorageSettings = () => {
},
});
const { data: storageLabel } = useQuery({
queryKey: ["storageLabel", settings.downloadStorageLocation],
queryFn: () => getStorageLabel(settings.downloadStorageLocation),
enabled: Platform.OS === "android",
});
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
@@ -102,14 +115,32 @@ export const StorageSettings = () => {
</View>
</View>
{!Platform.isTV && (
<ListGroup>
<ListItem
textColor='red'
onPress={onDeleteClicked}
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
<>
{Platform.OS === "android" && (
<ListGroup>
<ListItem
title={t("settings.storage.download_location", {
defaultValue: "Download Location",
})}
value={storageLabel || "Internal Storage"}
onPress={() => bottomSheetModalRef.current?.present()}
/>
</ListGroup>
)}
<ListGroup>
<ListItem
textColor='red'
onPress={onDeleteClicked}
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
</>
)}
<StorageLocationPicker
ref={bottomSheetModalRef}
onClose={() => bottomSheetModalRef.current?.dismiss()}
/>
</View>
);
};

View File

@@ -48,57 +48,49 @@ export const CenterControls: FC<CenterControlsProps> = ({
}}
pointerEvents={showControls ? "box-none" : "none"}
>
{settings?.showBrightnessSlider && (
<View
style={{
position: "absolute",
alignItems: "center",
transform: [{ rotate: "270deg" }],
left: 0,
bottom: 30,
}}
>
<BrightnessSlider />
</View>
)}
<View
style={{
position: "absolute",
alignItems: "center",
transform: [{ rotate: "270deg" }],
left: 0,
bottom: 30,
}}
>
<BrightnessSlider />
</View>
{!Platform.isTV ? (
settings?.showSeekButtons ? (
<TouchableOpacity onPress={handleSkipBackward}>
<View
{!Platform.isTV && (
<TouchableOpacity onPress={handleSkipBackward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='refresh-outline'
size={ICON_SIZES.CENTER}
color='white'
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
<Ionicons
name='refresh-outline'
size={ICON_SIZES.CENTER}
color='white'
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.rewindSkipTime}
</Text>
</View>
</TouchableOpacity>
) : (
<View
style={{ width: ICON_SIZES.CENTER, height: ICON_SIZES.CENTER }}
/>
)
) : null}
{settings?.rewindSkipTime}
</Text>
</View>
</TouchableOpacity>
)}
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
<TouchableOpacity onPress={togglePlay}>
@@ -114,55 +106,47 @@ export const CenterControls: FC<CenterControlsProps> = ({
</TouchableOpacity>
</View>
{!Platform.isTV ? (
settings?.showSeekButtons ? (
<TouchableOpacity onPress={handleSkipForward}>
<View
{!Platform.isTV && (
<TouchableOpacity onPress={handleSkipForward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='refresh-outline'
size={ICON_SIZES.CENTER}
color='white'
/>
<Text
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
<Ionicons
name='refresh-outline'
size={ICON_SIZES.CENTER}
color='white'
/>
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.forwardSkipTime}
</Text>
</View>
</TouchableOpacity>
) : (
<View
style={{ width: ICON_SIZES.CENTER, height: ICON_SIZES.CENTER }}
/>
)
) : null}
{settings?.showVolumeSlider && (
<View
style={{
position: "absolute",
alignItems: "center",
transform: [{ rotate: "270deg" }],
bottom: 30,
right: 0,
opacity: showAudioSlider || showControls ? 1 : 0,
}}
>
<AudioSlider setVisibility={setShowAudioSlider} />
</View>
{settings?.forwardSkipTime}
</Text>
</View>
</TouchableOpacity>
)}
<View
style={{
position: "absolute",
alignItems: "center",
transform: [{ rotate: "270deg" }],
bottom: 30,
right: 0,
opacity: showAudioSlider || showControls ? 1 : 0,
}}
>
<AudioSlider setVisibility={setShowAudioSlider} />
</View>
</View>
);
};

View File

@@ -244,22 +244,6 @@ export class JellyseerrApi {
.then(({ data }) => data);
}
async approveRequest(requestId: number): Promise<MediaRequest> {
return this.axios
?.post<MediaRequest>(
`${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/approve`,
)
.then(({ data }) => data);
}
async declineRequest(requestId: number): Promise<MediaRequest> {
return this.axios
?.post<MediaRequest>(
`${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/decline`,
)
.then(({ data }) => data);
}
async requests(
params = {
filter: "all",

View File

@@ -4,11 +4,16 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.Environment
import android.os.IBinder
import android.os.storage.StorageManager
import android.os.storage.StorageVolume
import android.util.Log
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File
data class DownloadTaskInfo(
val url: String,
@@ -142,6 +147,105 @@ class BackgroundDownloaderModule : Module() {
promise.reject("ERROR", "Failed to get active downloads: ${e.message}", e)
}
}
AsyncFunction("getAvailableStorageLocations") { promise: Promise ->
try {
val storageLocations = mutableListOf<Map<String, Any>>()
// Use getExternalFilesDirs which works reliably across all Android versions
// This returns app-specific directories on both internal and external storage
val externalDirs = context.getExternalFilesDirs(null)
Log.d(TAG, "getExternalFilesDirs returned ${externalDirs.size} locations")
// Also check with StorageManager for additional info
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
val volumes = storageManager.storageVolumes
Log.d(TAG, "StorageManager reports ${volumes.size} volumes")
for ((i, vol) in volumes.withIndex()) {
Log.d(TAG, " Volume $i: isPrimary=${vol.isPrimary}, isRemovable=${vol.isRemovable}, state=${vol.state}, uuid=${vol.uuid}")
}
}
for ((index, dir) in externalDirs.withIndex()) {
try {
if (dir == null) {
Log.w(TAG, "Directory at index $index is null - SD card may not be mounted")
continue
}
if (!dir.exists()) {
Log.w(TAG, "Directory at index $index does not exist: ${dir.absolutePath}")
continue
}
val isPrimary = index == 0
val isRemovable = !isPrimary && Environment.isExternalStorageRemovable(dir)
// Get volume UUID for better identification
val volumeId = if (isPrimary) {
"internal"
} else {
// Try to get a stable UUID for the SD card
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
try {
val storageVolume = storageManager.getStorageVolume(dir)
storageVolume?.uuid ?: "sdcard_$index"
} catch (e: Exception) {
"sdcard_$index"
}
} else {
"sdcard_$index"
}
}
// Get human-readable label
val label = if (isPrimary) {
"Internal Storage"
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
try {
val storageVolume = storageManager.getStorageVolume(dir)
storageVolume?.getDescription(context) ?: "SD Card"
} catch (e: Exception) {
"SD Card"
}
} else {
"SD Card"
}
}
val totalSpace = dir.totalSpace
val freeSpace = dir.freeSpace
Log.d(TAG, "Storage location: $label (id: $volumeId, path: ${dir.absolutePath}, removable: $isRemovable)")
storageLocations.add(
mapOf(
"id" to volumeId,
"path" to dir.absolutePath,
"type" to (if (isRemovable || !isPrimary) "external" else "internal"),
"label" to label,
"totalSpace" to totalSpace,
"freeSpace" to freeSpace
)
)
} catch (e: Exception) {
Log.e(TAG, "Error processing storage at index $index: ${e.message}", e)
continue
}
}
Log.d(TAG, "Returning ${storageLocations.size} storage locations")
promise.resolve(storageLocations)
} catch (e: Exception) {
Log.e(TAG, "Error getting storage locations: ${e.message}", e)
promise.reject("ERROR", "Failed to get storage locations: ${e.message}", e)
}
}
}
private fun startDownloadInternal(urlString: String, destinationPath: String?): Int {

View File

@@ -5,6 +5,7 @@ import type {
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
StorageLocation,
} from "./src/BackgroundDownloader.types";
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
@@ -15,6 +16,7 @@ export interface BackgroundDownloader {
cancelQueuedDownload(url: string): void;
cancelAllDownloads(): void;
getActiveDownloads(): Promise<ActiveDownload[]>;
getAvailableStorageLocations(): Promise<StorageLocation[]>;
addProgressListener(
listener: (event: DownloadProgressEvent) => void,
@@ -64,6 +66,10 @@ const BackgroundDownloader: BackgroundDownloader = {
return await BackgroundDownloaderModule.getActiveDownloads();
},
async getAvailableStorageLocations(): Promise<StorageLocation[]> {
return await BackgroundDownloaderModule.getAvailableStorageLocations();
},
addProgressListener(
listener: (event: DownloadProgressEvent) => void,
): EventSubscription {
@@ -106,4 +112,5 @@ export type {
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
StorageLocation,
};

View File

@@ -29,6 +29,15 @@ export interface ActiveDownload {
state: "running" | "suspended" | "canceling" | "completed" | "unknown";
}
export interface StorageLocation {
id: string;
path: string;
type: "internal" | "external";
label: string;
totalSpace: number;
freeSpace: number;
}
export interface BackgroundDownloaderModuleType {
startDownload(url: string, destinationPath?: string): Promise<number>;
enqueueDownload(url: string, destinationPath?: string): Promise<number>;
@@ -36,6 +45,7 @@ export interface BackgroundDownloaderModuleType {
cancelQueuedDownload(url: string): void;
cancelAllDownloads(): void;
getActiveDownloads(): Promise<ActiveDownload[]>;
getAvailableStorageLocations(): Promise<StorageLocation[]>;
addListener(
eventName: string,
listener: (event: any) => void,

View File

@@ -18,6 +18,7 @@ export type {
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
StorageLocation,
} from "./background-downloader";
// Background Downloader
export { default as BackgroundDownloader } from "./background-downloader";

View File

@@ -0,0 +1,68 @@
const { withAppDelegate, withXcodeProject } = require("expo/config-plugins");
const fs = require("node:fs");
const path = require("node:path");
/** @param {import("expo/config-plugins").ExpoConfig} config */
function withRNBackgroundDownloader(config) {
/* 1⃣ Add handleEventsForBackgroundURLSession to AppDelegate.swift */
config = withAppDelegate(config, (mod) => {
const tag = "handleEventsForBackgroundURLSession";
if (!mod.modResults.contents.includes(tag)) {
mod.modResults.contents = mod.modResults.contents.replace(
/\}\s*$/, // insert before final }
`
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
RNBackgroundDownloader.setCompletionHandlerWithIdentifier(identifier, completionHandler: completionHandler)
}
}`,
);
}
return mod;
});
/* 2⃣ Ensure bridging header exists & is attached to *every* app target */
config = withXcodeProject(config, (mod) => {
const project = mod.modResults;
const projectName = config.name || "App";
// Fix: Go up one more directory to get to ios/, not ios/ProjectName.xcodeproj/
const iosDir = path.dirname(path.dirname(project.filepath));
const headerRel = `${projectName}/${projectName}-Bridging-Header.h`;
const headerAbs = path.join(iosDir, headerRel);
// create / append import if missing
let headerText = "";
try {
headerText = fs.readFileSync(headerAbs, "utf8");
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
}
}
if (!headerText.includes("RNBackgroundDownloader.h")) {
fs.mkdirSync(path.dirname(headerAbs), { recursive: true });
fs.appendFileSync(headerAbs, '#import "RNBackgroundDownloader.h"\n');
}
// Expo 53's xcodejs doesn't expose pbxTargets().
// Setting the property once at the project level is sufficient.
["Debug", "Release"].forEach((cfg) => {
// Use the detected projectName to set the bridging header path instead of a hardcoded value
const bridgingHeaderPath = `${projectName}/${projectName}-Bridging-Header.h`;
project.updateBuildProperty(
"SWIFT_OBJC_BRIDGING_HEADER",
bridgingHeaderPath,
cfg,
);
});
return mod;
});
return config;
}
module.exports = withRNBackgroundDownloader;

View File

@@ -6,16 +6,20 @@ import type {
import { Directory, File, Paths } from "expo-file-system";
import { getItemImage } from "@/utils/getItemImage";
import { fetchAndParseSegments } from "@/utils/segments";
import { filePathToUri } from "@/utils/storage";
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
import type { MediaTimeSegment, TrickPlayData } from "./types";
import { generateFilename } from "./utils";
/**
* Downloads trickplay images for an item
* @param item - The item to download trickplay images for
* @param storagePath - Optional custom storage path (for Android SD card support)
* @returns TrickPlayData with path and size, or undefined if not available
*/
export async function downloadTrickplayImages(
item: BaseItemDto,
storagePath?: string,
): Promise<TrickPlayData | undefined> {
const trickplayInfo = getTrickplayInfo(item);
if (!trickplayInfo || !item.Id) {
@@ -23,7 +27,11 @@ export async function downloadTrickplayImages(
}
const filename = generateFilename(item);
const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`);
// Use custom storage path if provided (Android SD card), otherwise use Paths.document
const trickplayDir = storagePath
? new Directory(filePathToUri(storagePath), `${filename}_trickplay`)
: new Directory(Paths.document, `${filename}_trickplay`);
// Create directory if it doesn't exist
if (!trickplayDir.exists) {
@@ -69,12 +77,17 @@ export async function downloadTrickplayImages(
/**
* Downloads external subtitle files and updates their delivery URLs to local paths
* @param mediaSource - The media source containing subtitle information
* @param item - The item to download subtitles for
* @param apiBasePath - The base path for the API
* @param storagePath - Optional custom storage path (for Android SD card support)
* @returns Updated media source with local subtitle paths
*/
export async function downloadSubtitles(
mediaSource: MediaSourceInfo,
item: BaseItemDto,
apiBasePath: string,
storagePath?: string,
): Promise<MediaSourceInfo> {
const externalSubtitles = mediaSource.MediaStreams?.filter(
(stream) =>
@@ -91,10 +104,17 @@ export async function downloadSubtitles(
const url = apiBasePath + subtitle.DeliveryUrl;
const extension = subtitle.Codec || "srt";
const destination = new File(
Paths.document,
`${filename}_subtitle_${subtitle.Index}.${extension}`,
);
// Use custom storage path if provided (Android SD card), otherwise use Paths.document
const destination = storagePath
? new File(
filePathToUri(storagePath),
`${filename}_subtitle_${subtitle.Index}.${extension}`,
)
: new File(
Paths.document,
`${filename}_subtitle_${subtitle.Index}.${extension}`,
);
// Skip if already exists
if (destination.exists) {
@@ -208,13 +228,21 @@ export async function downloadAdditionalAssets(params: {
api: Api;
saveImageFn: (itemId: string, url?: string) => Promise<void>;
saveSeriesImageFn: (item: BaseItemDto) => Promise<void>;
storagePath?: string;
}): Promise<{
trickPlayData?: TrickPlayData;
updatedMediaSource: MediaSourceInfo;
introSegments?: MediaTimeSegment[];
creditSegments?: MediaTimeSegment[];
}> {
const { item, mediaSource, api, saveImageFn, saveSeriesImageFn } = params;
const {
item,
mediaSource,
api,
saveImageFn,
saveSeriesImageFn,
storagePath,
} = params;
// Run all downloads in parallel for speed
const [
@@ -223,11 +251,11 @@ export async function downloadAdditionalAssets(params: {
segments,
// Cover images (fire and forget, errors are logged)
] = await Promise.all([
downloadTrickplayImages(item),
downloadTrickplayImages(item, storagePath),
// Only download subtitles for non-transcoded streams
mediaSource.TranscodingUrl
? Promise.resolve(mediaSource)
: downloadSubtitles(mediaSource, item, api.basePath || ""),
: downloadSubtitles(mediaSource, item, api.basePath || "", storagePath),
item.Id
? fetchSegments(item.Id, api)
: Promise.resolve({

View File

@@ -1,4 +1,4 @@
import { Directory, File, Paths } from "expo-file-system";
import { Directory, File } from "expo-file-system";
import { getAllDownloadedItems, getDownloadedItemById } from "./database";
import type { DownloadedItem } from "./types";
import { filePathToUri } from "./utils";
@@ -39,13 +39,11 @@ export function deleteAllAssociatedFiles(item: DownloadedItem): void {
stream.DeliveryUrl
) {
try {
const subtitleFilename = stream.DeliveryUrl.split("/").pop();
if (subtitleFilename) {
const subtitleFile = new File(Paths.document, subtitleFilename);
if (subtitleFile.exists) {
subtitleFile.delete();
console.log(`[DELETE] Subtitle deleted: ${subtitleFilename}`);
}
// Use the full path from DeliveryUrl (it's already a full file:// URI)
const subtitleFile = new File(stream.DeliveryUrl);
if (subtitleFile.exists) {
subtitleFile.delete();
console.log(`[DELETE] Subtitle deleted: ${stream.DeliveryUrl}`);
}
} catch (error) {
console.error("[DELETE] Failed to delete subtitle:", error);
@@ -57,15 +55,13 @@ export function deleteAllAssociatedFiles(item: DownloadedItem): void {
// Delete trickplay directory
if (item.trickPlayData?.path) {
try {
const trickplayDirName = item.trickPlayData.path.split("/").pop();
if (trickplayDirName) {
const trickplayDir = new Directory(Paths.document, trickplayDirName);
if (trickplayDir.exists) {
trickplayDir.delete();
console.log(
`[DELETE] Trickplay directory deleted: ${trickplayDirName}`,
);
}
// Use the full path from trickPlayData (it's already a full file:// URI)
const trickplayDir = new Directory(item.trickPlayData.path);
if (trickplayDir.exists) {
trickplayDir.delete();
console.log(
`[DELETE] Trickplay directory deleted: ${item.trickPlayData.path}`,
);
}
} catch (error) {
console.error("[DELETE] Failed to delete trickplay directory:", error);

View File

@@ -6,13 +6,16 @@ import { File, Paths } from "expo-file-system";
import type { MutableRefObject } from "react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import DeviceInfo from "react-native-device-info";
import { toast } from "sonner-native";
import type { Bitrate } from "@/components/BitrateSelector";
import useImageStorage from "@/hooks/useImageStorage";
import { BackgroundDownloader } from "@/modules";
import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import useDownloadHelper from "@/utils/download";
import { getStoragePath } from "@/utils/storage";
import { downloadAdditionalAssets } from "../additionalDownloads";
import {
clearAllDownloadedItems,
@@ -49,6 +52,7 @@ export function useDownloadOperations({
onDataChange,
}: UseDownloadOperationsProps) {
const { t } = useTranslation();
const { settings } = useSettings();
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveImage } = useImageStorage();
@@ -79,6 +83,12 @@ export function useDownloadOperations({
return;
}
// Get storage path if custom location is set
let storagePath: string | undefined;
if (Platform.OS === "android" && settings.downloadStorageLocation) {
storagePath = await getStoragePath(settings.downloadStorageLocation);
}
// Download all additional assets BEFORE starting native video download
const additionalAssets = await downloadAdditionalAssets({
item,
@@ -86,6 +96,7 @@ export function useDownloadOperations({
api,
saveImageFn: saveImage,
saveSeriesImageFn: saveSeriesPrimaryImage,
storagePath,
});
// Ensure URL is absolute (not relative) before storing
@@ -119,10 +130,19 @@ export function useDownloadOperations({
// Add to processes
setProcesses((prev) => [...prev, jobStatus]);
// Generate destination path
// Generate destination path using custom storage location if set
const filename = generateFilename(item);
const videoFile = new File(Paths.document, `${filename}.mp4`);
const destinationPath = uriToFilePath(videoFile.uri);
let destinationPath: string;
if (storagePath) {
// Use custom storage location
destinationPath = `${storagePath}/${filename}.mp4`;
console.log(`[DOWNLOAD] Using custom storage: ${destinationPath}`);
} else {
// Use default Paths.document
const videoFile = new File(Paths.document, `${filename}.mp4`);
destinationPath = uriToFilePath(videoFile.uri);
}
console.log(`[DOWNLOAD] Starting video: ${item.Name}`);
console.log(`[DOWNLOAD] Download URL: ${downloadUrl}`);

View File

@@ -113,15 +113,6 @@
"right_side_volume": "Right Side Volume Control",
"right_side_volume_description": "Swipe up/down on right side to adjust volume"
},
"controls": {
"controls_title": "Controls",
"show_volume_slider": "Show Volume Slider",
"show_volume_slider_description": "Display volume slider on the right side of video controls",
"show_brightness_slider": "Show Brightness Slider",
"show_brightness_slider_description": "Display brightness slider on the left side of video controls",
"show_seek_buttons": "Show Seek Buttons",
"show_seek_buttons_description": "Display forward/rewind buttons next to the play button"
},
"audio": {
"audio_title": "Audio",
"set_audio_track": "Set Audio Track From Previous Item",
@@ -438,12 +429,7 @@
"playback_state": "Playback State:",
"index": "Index:",
"continue_watching": "Continue Watching",
"go_back": "Go Back",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"go_back": "Go Back"
},
"item_card": {
"next_up": "Next Up",
@@ -528,10 +514,6 @@
"number_episodes": "{{episode_number}} Episodes",
"born": "Born",
"appearances": "Appearances",
"approve": "Approve",
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
@@ -539,11 +521,7 @@
"issue_submitted": "Issue Submitted!",
"requested_item": "Requested {{item}}!",
"you_dont_have_permission_to_request": "You don't have permission to request!",
"something_went_wrong_requesting_media": "Something went wrong requesting media!",
"request_approved": "Request Approved!",
"request_declined": "Request Declined!",
"failed_to_approve_request": "Failed to Approve Request",
"failed_to_decline_request": "Failed to Decline Request"
"something_went_wrong_requesting_media": "Something went wrong requesting media!"
}
},
"tabs": {

View File

@@ -145,6 +145,7 @@ export type Settings = {
marlinServerUrl?: string;
openInVLC?: boolean;
downloadQuality?: DownloadOption;
downloadStorageLocation?: string;
defaultBitrate?: Bitrate;
libraryOptions: LibraryOptions;
defaultAudioLanguage: CultureDto | null;
@@ -180,10 +181,6 @@ export type Settings = {
enableRightSideVolumeSwipe: boolean;
usePopularPlugin: boolean;
showLargeHomeCarousel: boolean;
// Controls
showVolumeSlider: boolean;
showBrightnessSlider: boolean;
showSeekButtons: boolean;
};
export interface Lockable<T> {
@@ -207,6 +204,7 @@ export const defaultValues: Settings = {
marlinServerUrl: "",
openInVLC: false,
downloadQuality: DownloadOptions[0],
downloadStorageLocation: undefined,
defaultBitrate: BITRATES[0],
libraryOptions: {
display: "list",
@@ -248,10 +246,6 @@ export const defaultValues: Settings = {
enableRightSideVolumeSwipe: true,
usePopularPlugin: true,
showLargeHomeCarousel: false,
// Controls
showVolumeSlider: true,
showBrightnessSlider: true,
showSeekButtons: true,
};
const loadSettings = (): Partial<Settings> => {

143
utils/storage.ts Normal file
View File

@@ -0,0 +1,143 @@
import { Directory, Paths } from "expo-file-system";
import { Platform } from "react-native";
import { BackgroundDownloader, type StorageLocation } from "@/modules";
let cachedStorageLocations: StorageLocation[] | null = null;
// Debug mode: Set to true to simulate an SD card for testing in emulator
// This creates a real writable directory that mimics SD card behavior
const DEBUG_SIMULATE_SD_CARD = false;
/**
* Get all available storage locations (Android only)
* Returns cached result on subsequent calls
*/
export async function getAvailableStorageLocations(): Promise<
StorageLocation[]
> {
if (Platform.OS !== "android") {
return [];
}
if (cachedStorageLocations !== null) {
return cachedStorageLocations;
}
try {
const locations = await BackgroundDownloader.getAvailableStorageLocations();
// Debug mode: Add a functional simulated SD card for testing
if (DEBUG_SIMULATE_SD_CARD && locations.length === 1) {
// Use a real writable path within the app's document directory
const sdcardSimDir = new Directory(Paths.document, "sdcard_sim");
// Create the directory if it doesn't exist
if (!sdcardSimDir.exists) {
sdcardSimDir.create({ intermediates: true });
}
const mockSdCard: StorageLocation = {
id: "sdcard_sim",
path: sdcardSimDir.uri.replace("file://", ""),
type: "external",
label: "SD Card (Simulated)",
totalSpace: 64 * 1024 * 1024 * 1024, // 64 GB
freeSpace: 32 * 1024 * 1024 * 1024, // 32 GB free
};
locations.push(mockSdCard);
console.log("[DEBUG] Added simulated SD card:", mockSdCard.path);
}
cachedStorageLocations = locations;
return locations;
} catch (error) {
console.error("Failed to get storage locations:", error);
return [];
}
}
/**
* Clear the cached storage locations
* Useful when storage configuration might have changed
*/
export function clearStorageLocationsCache(): void {
cachedStorageLocations = null;
console.log("[Storage] Cache cleared");
}
/**
* Get a simplified label for a storage location ID
* @param storageId - The storage location ID (e.g., "internal", "sdcard_0")
* @returns Human-readable label (e.g., "Internal Storage", "SD Card")
*/
export async function getStorageLabel(storageId?: string): Promise<string> {
if (!storageId || Platform.OS !== "android") {
return "Internal Storage";
}
const locations = await getAvailableStorageLocations();
const location = locations.find((loc) => loc.id === storageId);
return location?.label || "Internal Storage";
}
/**
* Get the filesystem path for a storage location ID
* @param storageId - The storage location ID (e.g., "internal", "sdcard_0")
* @returns The filesystem path, or default path if not found
*/
export async function getStoragePath(storageId?: string): Promise<string> {
if (!storageId || Platform.OS !== "android") {
return getDefaultStoragePath();
}
const locations = await getAvailableStorageLocations();
const location = locations.find((loc) => loc.id === storageId);
if (!location) {
console.warn(`Storage location not found: ${storageId}, using default`);
return getDefaultStoragePath();
}
return location.path;
}
/**
* Get the default storage path (current behavior using Paths.document)
* @returns The default storage path
*/
export function getDefaultStoragePath(): string {
// Paths.document returns a Directory with a URI like "file:///data/user/0/..."
// We need to extract the actual path
const uri = Paths.document.uri;
return uri.replace("file://", "");
}
/**
* Get a storage location by ID
* @param storageId - The storage location ID
* @returns The storage location or undefined if not found
*/
export async function getStorageLocationById(
storageId?: string,
): Promise<StorageLocation | undefined> {
if (!storageId || Platform.OS !== "android") {
return undefined;
}
const locations = await getAvailableStorageLocations();
return locations.find((loc) => loc.id === storageId);
}
/**
* Convert plain file path to file:// URI
* Required for expo-file-system File constructor
* @param path - The file path
* @returns The file:// URI
*/
export function filePathToUri(path: string): string {
if (path.startsWith("file://")) {
return path;
}
return `file://${path}`;
}