mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-21 11:32:24 +00:00
Compare commits
2 Commits
develop
...
feat/andro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16bb1b8717 | ||
|
|
259306df52 |
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 { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
@@ -6,14 +8,19 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getStorageLabel } from "@/utils/storage";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { StorageLocationPicker } from "./StorageLocationPicker";
|
||||||
|
|
||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const { data: size } = useQuery({
|
const { data: size } = useQuery({
|
||||||
queryKey: ["appSize"],
|
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 () => {
|
const onDeleteClicked = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAllFiles();
|
await deleteAllFiles();
|
||||||
@@ -102,14 +115,32 @@ export const StorageSettings = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<ListGroup>
|
<>
|
||||||
<ListItem
|
{Platform.OS === "android" && (
|
||||||
textColor='red'
|
<ListGroup>
|
||||||
onPress={onDeleteClicked}
|
<ListItem
|
||||||
title={t("home.settings.storage.delete_all_downloaded_files")}
|
title={t("settings.storage.download_location", {
|
||||||
/>
|
defaultValue: "Download Location",
|
||||||
</ListGroup>
|
})}
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ import android.content.ComponentName
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.storage.StorageManager
|
||||||
|
import android.os.storage.StorageVolume
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import expo.modules.kotlin.Promise
|
import expo.modules.kotlin.Promise
|
||||||
import expo.modules.kotlin.modules.Module
|
import expo.modules.kotlin.modules.Module
|
||||||
import expo.modules.kotlin.modules.ModuleDefinition
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
data class DownloadTaskInfo(
|
data class DownloadTaskInfo(
|
||||||
val url: String,
|
val url: String,
|
||||||
@@ -142,6 +147,105 @@ class BackgroundDownloaderModule : Module() {
|
|||||||
promise.reject("ERROR", "Failed to get active downloads: ${e.message}", e)
|
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 {
|
private fun startDownloadInternal(urlString: String, destinationPath: String?): Int {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
DownloadErrorEvent,
|
DownloadErrorEvent,
|
||||||
DownloadProgressEvent,
|
DownloadProgressEvent,
|
||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
|
StorageLocation,
|
||||||
} from "./src/BackgroundDownloader.types";
|
} from "./src/BackgroundDownloader.types";
|
||||||
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
|
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export interface BackgroundDownloader {
|
|||||||
cancelQueuedDownload(url: string): void;
|
cancelQueuedDownload(url: string): void;
|
||||||
cancelAllDownloads(): void;
|
cancelAllDownloads(): void;
|
||||||
getActiveDownloads(): Promise<ActiveDownload[]>;
|
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||||
|
getAvailableStorageLocations(): Promise<StorageLocation[]>;
|
||||||
|
|
||||||
addProgressListener(
|
addProgressListener(
|
||||||
listener: (event: DownloadProgressEvent) => void,
|
listener: (event: DownloadProgressEvent) => void,
|
||||||
@@ -64,6 +66,10 @@ const BackgroundDownloader: BackgroundDownloader = {
|
|||||||
return await BackgroundDownloaderModule.getActiveDownloads();
|
return await BackgroundDownloaderModule.getActiveDownloads();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getAvailableStorageLocations(): Promise<StorageLocation[]> {
|
||||||
|
return await BackgroundDownloaderModule.getAvailableStorageLocations();
|
||||||
|
},
|
||||||
|
|
||||||
addProgressListener(
|
addProgressListener(
|
||||||
listener: (event: DownloadProgressEvent) => void,
|
listener: (event: DownloadProgressEvent) => void,
|
||||||
): EventSubscription {
|
): EventSubscription {
|
||||||
@@ -106,4 +112,5 @@ export type {
|
|||||||
DownloadErrorEvent,
|
DownloadErrorEvent,
|
||||||
DownloadProgressEvent,
|
DownloadProgressEvent,
|
||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
|
StorageLocation,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ export interface ActiveDownload {
|
|||||||
state: "running" | "suspended" | "canceling" | "completed" | "unknown";
|
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 {
|
export interface BackgroundDownloaderModuleType {
|
||||||
startDownload(url: string, destinationPath?: string): Promise<number>;
|
startDownload(url: string, destinationPath?: string): Promise<number>;
|
||||||
enqueueDownload(url: string, destinationPath?: string): Promise<number>;
|
enqueueDownload(url: string, destinationPath?: string): Promise<number>;
|
||||||
@@ -36,6 +45,7 @@ export interface BackgroundDownloaderModuleType {
|
|||||||
cancelQueuedDownload(url: string): void;
|
cancelQueuedDownload(url: string): void;
|
||||||
cancelAllDownloads(): void;
|
cancelAllDownloads(): void;
|
||||||
getActiveDownloads(): Promise<ActiveDownload[]>;
|
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||||
|
getAvailableStorageLocations(): Promise<StorageLocation[]>;
|
||||||
addListener(
|
addListener(
|
||||||
eventName: string,
|
eventName: string,
|
||||||
listener: (event: any) => void,
|
listener: (event: any) => void,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type {
|
|||||||
DownloadErrorEvent,
|
DownloadErrorEvent,
|
||||||
DownloadProgressEvent,
|
DownloadProgressEvent,
|
||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
|
StorageLocation,
|
||||||
} from "./background-downloader";
|
} from "./background-downloader";
|
||||||
// Background Downloader
|
// Background Downloader
|
||||||
export { default as BackgroundDownloader } from "./background-downloader";
|
export { default as BackgroundDownloader } from "./background-downloader";
|
||||||
|
|||||||
@@ -6,16 +6,20 @@ import type {
|
|||||||
import { Directory, File, Paths } from "expo-file-system";
|
import { Directory, File, Paths } from "expo-file-system";
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import { fetchAndParseSegments } from "@/utils/segments";
|
import { fetchAndParseSegments } from "@/utils/segments";
|
||||||
|
import { filePathToUri } from "@/utils/storage";
|
||||||
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
|
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
|
||||||
import type { MediaTimeSegment, TrickPlayData } from "./types";
|
import type { MediaTimeSegment, TrickPlayData } from "./types";
|
||||||
import { generateFilename } from "./utils";
|
import { generateFilename } from "./utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads trickplay images for an item
|
* 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
|
* @returns TrickPlayData with path and size, or undefined if not available
|
||||||
*/
|
*/
|
||||||
export async function downloadTrickplayImages(
|
export async function downloadTrickplayImages(
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
|
storagePath?: string,
|
||||||
): Promise<TrickPlayData | undefined> {
|
): Promise<TrickPlayData | undefined> {
|
||||||
const trickplayInfo = getTrickplayInfo(item);
|
const trickplayInfo = getTrickplayInfo(item);
|
||||||
if (!trickplayInfo || !item.Id) {
|
if (!trickplayInfo || !item.Id) {
|
||||||
@@ -23,7 +27,11 @@ export async function downloadTrickplayImages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filename = generateFilename(item);
|
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
|
// Create directory if it doesn't exist
|
||||||
if (!trickplayDir.exists) {
|
if (!trickplayDir.exists) {
|
||||||
@@ -69,12 +77,17 @@ export async function downloadTrickplayImages(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads external subtitle files and updates their delivery URLs to local paths
|
* 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
|
* @returns Updated media source with local subtitle paths
|
||||||
*/
|
*/
|
||||||
export async function downloadSubtitles(
|
export async function downloadSubtitles(
|
||||||
mediaSource: MediaSourceInfo,
|
mediaSource: MediaSourceInfo,
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
apiBasePath: string,
|
apiBasePath: string,
|
||||||
|
storagePath?: string,
|
||||||
): Promise<MediaSourceInfo> {
|
): Promise<MediaSourceInfo> {
|
||||||
const externalSubtitles = mediaSource.MediaStreams?.filter(
|
const externalSubtitles = mediaSource.MediaStreams?.filter(
|
||||||
(stream) =>
|
(stream) =>
|
||||||
@@ -91,10 +104,17 @@ export async function downloadSubtitles(
|
|||||||
|
|
||||||
const url = apiBasePath + subtitle.DeliveryUrl;
|
const url = apiBasePath + subtitle.DeliveryUrl;
|
||||||
const extension = subtitle.Codec || "srt";
|
const extension = subtitle.Codec || "srt";
|
||||||
const destination = new File(
|
|
||||||
Paths.document,
|
// Use custom storage path if provided (Android SD card), otherwise use Paths.document
|
||||||
`${filename}_subtitle_${subtitle.Index}.${extension}`,
|
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
|
// Skip if already exists
|
||||||
if (destination.exists) {
|
if (destination.exists) {
|
||||||
@@ -208,13 +228,21 @@ export async function downloadAdditionalAssets(params: {
|
|||||||
api: Api;
|
api: Api;
|
||||||
saveImageFn: (itemId: string, url?: string) => Promise<void>;
|
saveImageFn: (itemId: string, url?: string) => Promise<void>;
|
||||||
saveSeriesImageFn: (item: BaseItemDto) => Promise<void>;
|
saveSeriesImageFn: (item: BaseItemDto) => Promise<void>;
|
||||||
|
storagePath?: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
trickPlayData?: TrickPlayData;
|
trickPlayData?: TrickPlayData;
|
||||||
updatedMediaSource: MediaSourceInfo;
|
updatedMediaSource: MediaSourceInfo;
|
||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
creditSegments?: 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
|
// Run all downloads in parallel for speed
|
||||||
const [
|
const [
|
||||||
@@ -223,11 +251,11 @@ export async function downloadAdditionalAssets(params: {
|
|||||||
segments,
|
segments,
|
||||||
// Cover images (fire and forget, errors are logged)
|
// Cover images (fire and forget, errors are logged)
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
downloadTrickplayImages(item),
|
downloadTrickplayImages(item, storagePath),
|
||||||
// Only download subtitles for non-transcoded streams
|
// Only download subtitles for non-transcoded streams
|
||||||
mediaSource.TranscodingUrl
|
mediaSource.TranscodingUrl
|
||||||
? Promise.resolve(mediaSource)
|
? Promise.resolve(mediaSource)
|
||||||
: downloadSubtitles(mediaSource, item, api.basePath || ""),
|
: downloadSubtitles(mediaSource, item, api.basePath || "", storagePath),
|
||||||
item.Id
|
item.Id
|
||||||
? fetchSegments(item.Id, api)
|
? fetchSegments(item.Id, api)
|
||||||
: Promise.resolve({
|
: 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 { getAllDownloadedItems, getDownloadedItemById } from "./database";
|
||||||
import type { DownloadedItem } from "./types";
|
import type { DownloadedItem } from "./types";
|
||||||
import { filePathToUri } from "./utils";
|
import { filePathToUri } from "./utils";
|
||||||
@@ -39,13 +39,11 @@ export function deleteAllAssociatedFiles(item: DownloadedItem): void {
|
|||||||
stream.DeliveryUrl
|
stream.DeliveryUrl
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const subtitleFilename = stream.DeliveryUrl.split("/").pop();
|
// Use the full path from DeliveryUrl (it's already a full file:// URI)
|
||||||
if (subtitleFilename) {
|
const subtitleFile = new File(stream.DeliveryUrl);
|
||||||
const subtitleFile = new File(Paths.document, subtitleFilename);
|
if (subtitleFile.exists) {
|
||||||
if (subtitleFile.exists) {
|
subtitleFile.delete();
|
||||||
subtitleFile.delete();
|
console.log(`[DELETE] Subtitle deleted: ${stream.DeliveryUrl}`);
|
||||||
console.log(`[DELETE] Subtitle deleted: ${subtitleFilename}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[DELETE] Failed to delete subtitle:", error);
|
console.error("[DELETE] Failed to delete subtitle:", error);
|
||||||
@@ -57,15 +55,13 @@ export function deleteAllAssociatedFiles(item: DownloadedItem): void {
|
|||||||
// Delete trickplay directory
|
// Delete trickplay directory
|
||||||
if (item.trickPlayData?.path) {
|
if (item.trickPlayData?.path) {
|
||||||
try {
|
try {
|
||||||
const trickplayDirName = item.trickPlayData.path.split("/").pop();
|
// Use the full path from trickPlayData (it's already a full file:// URI)
|
||||||
if (trickplayDirName) {
|
const trickplayDir = new Directory(item.trickPlayData.path);
|
||||||
const trickplayDir = new Directory(Paths.document, trickplayDirName);
|
if (trickplayDir.exists) {
|
||||||
if (trickplayDir.exists) {
|
trickplayDir.delete();
|
||||||
trickplayDir.delete();
|
console.log(
|
||||||
console.log(
|
`[DELETE] Trickplay directory deleted: ${item.trickPlayData.path}`,
|
||||||
`[DELETE] Trickplay directory deleted: ${trickplayDirName}`,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[DELETE] Failed to delete trickplay directory:", 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 type { MutableRefObject } from "react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import DeviceInfo from "react-native-device-info";
|
import DeviceInfo from "react-native-device-info";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import type { Bitrate } from "@/components/BitrateSelector";
|
import type { Bitrate } from "@/components/BitrateSelector";
|
||||||
import useImageStorage from "@/hooks/useImageStorage";
|
import useImageStorage from "@/hooks/useImageStorage";
|
||||||
import { BackgroundDownloader } from "@/modules";
|
import { BackgroundDownloader } from "@/modules";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getOrSetDeviceId } from "@/utils/device";
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
import useDownloadHelper from "@/utils/download";
|
import useDownloadHelper from "@/utils/download";
|
||||||
|
import { getStoragePath } from "@/utils/storage";
|
||||||
import { downloadAdditionalAssets } from "../additionalDownloads";
|
import { downloadAdditionalAssets } from "../additionalDownloads";
|
||||||
import {
|
import {
|
||||||
clearAllDownloadedItems,
|
clearAllDownloadedItems,
|
||||||
@@ -49,6 +52,7 @@ export function useDownloadOperations({
|
|||||||
onDataChange,
|
onDataChange,
|
||||||
}: UseDownloadOperationsProps) {
|
}: UseDownloadOperationsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||||
const { saveImage } = useImageStorage();
|
const { saveImage } = useImageStorage();
|
||||||
|
|
||||||
@@ -79,6 +83,12 @@ export function useDownloadOperations({
|
|||||||
return;
|
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
|
// Download all additional assets BEFORE starting native video download
|
||||||
const additionalAssets = await downloadAdditionalAssets({
|
const additionalAssets = await downloadAdditionalAssets({
|
||||||
item,
|
item,
|
||||||
@@ -86,6 +96,7 @@ export function useDownloadOperations({
|
|||||||
api,
|
api,
|
||||||
saveImageFn: saveImage,
|
saveImageFn: saveImage,
|
||||||
saveSeriesImageFn: saveSeriesPrimaryImage,
|
saveSeriesImageFn: saveSeriesPrimaryImage,
|
||||||
|
storagePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure URL is absolute (not relative) before storing
|
// Ensure URL is absolute (not relative) before storing
|
||||||
@@ -119,10 +130,19 @@ export function useDownloadOperations({
|
|||||||
// Add to processes
|
// Add to processes
|
||||||
setProcesses((prev) => [...prev, jobStatus]);
|
setProcesses((prev) => [...prev, jobStatus]);
|
||||||
|
|
||||||
// Generate destination path
|
// Generate destination path using custom storage location if set
|
||||||
const filename = generateFilename(item);
|
const filename = generateFilename(item);
|
||||||
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
let destinationPath: string;
|
||||||
const destinationPath = uriToFilePath(videoFile.uri);
|
|
||||||
|
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] Starting video: ${item.Name}`);
|
||||||
console.log(`[DOWNLOAD] Download URL: ${downloadUrl}`);
|
console.log(`[DOWNLOAD] Download URL: ${downloadUrl}`);
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export type Settings = {
|
|||||||
marlinServerUrl?: string;
|
marlinServerUrl?: string;
|
||||||
openInVLC?: boolean;
|
openInVLC?: boolean;
|
||||||
downloadQuality?: DownloadOption;
|
downloadQuality?: DownloadOption;
|
||||||
|
downloadStorageLocation?: string;
|
||||||
defaultBitrate?: Bitrate;
|
defaultBitrate?: Bitrate;
|
||||||
libraryOptions: LibraryOptions;
|
libraryOptions: LibraryOptions;
|
||||||
defaultAudioLanguage: CultureDto | null;
|
defaultAudioLanguage: CultureDto | null;
|
||||||
@@ -203,6 +204,7 @@ export const defaultValues: Settings = {
|
|||||||
marlinServerUrl: "",
|
marlinServerUrl: "",
|
||||||
openInVLC: false,
|
openInVLC: false,
|
||||||
downloadQuality: DownloadOptions[0],
|
downloadQuality: DownloadOptions[0],
|
||||||
|
downloadStorageLocation: undefined,
|
||||||
defaultBitrate: BITRATES[0],
|
defaultBitrate: BITRATES[0],
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
display: "list",
|
display: "list",
|
||||||
|
|||||||
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