mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
Compare commits
2 Commits
feat/hide-
...
feat/andro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16bb1b8717 | ||
|
|
259306df52 |
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" : ""}
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
212
components/settings/StorageLocationPicker.tsx
Normal file
212
components/settings/StorageLocationPicker.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,6 +18,7 @@ export type {
|
||||
DownloadErrorEvent,
|
||||
DownloadProgressEvent,
|
||||
DownloadStartedEvent,
|
||||
StorageLocation,
|
||||
} from "./background-downloader";
|
||||
// Background Downloader
|
||||
export { default as BackgroundDownloader } from "./background-downloader";
|
||||
|
||||
68
plugins/withRNBackgroundDownloader.js
Normal file
68
plugins/withRNBackgroundDownloader.js
Normal 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 xcode‑js 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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
143
utils/storage.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user