diff --git a/components/settings/StorageLocationPicker.tsx b/components/settings/StorageLocationPicker.tsx new file mode 100644 index 00000000..751d471b --- /dev/null +++ b/components/settings/StorageLocationPicker.tsx @@ -0,0 +1,208 @@ +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 { 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( + 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 }); + toast.success( + t("settings.storage.storage_location_updated", { + defaultValue: "Storage location updated", + }), + ); + onClose(); + }; + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + if (Platform.OS !== "android") { + return null; + } + + return ( + + + + + {t("settings.storage.select_storage_location", { + defaultValue: "Select Storage Location", + })} + + + {t("settings.storage.existing_downloads_note", { + defaultValue: + "Existing downloads will remain in their current location", + })} + + + {isLoading ? ( + + + + {t("settings.storage.loading_storage", { + defaultValue: "Loading storage options...", + })} + + + ) : !locations || locations.length === 0 ? ( + + + {t("settings.storage.no_storage_found", { + defaultValue: "No storage locations found", + })} + + + ) : ( + <> + {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 ( + handleSelect(location)} + className={`p-4 mb-2 rounded-lg ${ + isSelected + ? "bg-purple-600/20 border border-purple-600" + : "bg-neutral-800" + }`} + > + + + + + {location.label} + + {location.type === "external" && ( + + + {t("settings.storage.removable", { + defaultValue: "Removable", + })} + + + )} + + + {t("settings.storage.space_info", { + defaultValue: + "{{free}} GB free of {{total}} GB ({{used}}% used)", + free: freeSpaceGB, + total: totalSpaceGB, + used: usedPercent, + })} + + + {isSelected && ( + + + + )} + + + ); + })} + + + + + {t("common.cancel", { defaultValue: "Cancel" })} + + + + + {t("common.confirm", { defaultValue: "Confirm" })} + + + + + )} + + + + ); +}); diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx index 117152fc..da985da5 100644 --- a/components/settings/StorageSettings.tsx +++ b/components/settings/StorageSettings.tsx @@ -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(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 = () => { {!Platform.isTV && ( - - - + <> + {Platform.OS === "android" && ( + + bottomSheetModalRef.current?.present()} + /> + + )} + + + + )} + + bottomSheetModalRef.current?.dismiss()} + /> ); }; diff --git a/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/BackgroundDownloaderModule.kt b/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/BackgroundDownloaderModule.kt index a2913b20..7d46e70d 100644 --- a/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/BackgroundDownloaderModule.kt +++ b/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/BackgroundDownloaderModule.kt @@ -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>() + + // 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 { diff --git a/modules/background-downloader/index.ts b/modules/background-downloader/index.ts index 93acce01..852c3b5c 100644 --- a/modules/background-downloader/index.ts +++ b/modules/background-downloader/index.ts @@ -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; + getAvailableStorageLocations(): Promise; addProgressListener( listener: (event: DownloadProgressEvent) => void, @@ -64,6 +66,10 @@ const BackgroundDownloader: BackgroundDownloader = { return await BackgroundDownloaderModule.getActiveDownloads(); }, + async getAvailableStorageLocations(): Promise { + return await BackgroundDownloaderModule.getAvailableStorageLocations(); + }, + addProgressListener( listener: (event: DownloadProgressEvent) => void, ): EventSubscription { @@ -106,4 +112,5 @@ export type { DownloadErrorEvent, DownloadProgressEvent, DownloadStartedEvent, + StorageLocation, }; diff --git a/modules/background-downloader/src/BackgroundDownloader.types.ts b/modules/background-downloader/src/BackgroundDownloader.types.ts index eae34f32..e31aa881 100644 --- a/modules/background-downloader/src/BackgroundDownloader.types.ts +++ b/modules/background-downloader/src/BackgroundDownloader.types.ts @@ -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; enqueueDownload(url: string, destinationPath?: string): Promise; @@ -36,6 +45,7 @@ export interface BackgroundDownloaderModuleType { cancelQueuedDownload(url: string): void; cancelAllDownloads(): void; getActiveDownloads(): Promise; + getAvailableStorageLocations(): Promise; addListener( eventName: string, listener: (event: any) => void, diff --git a/modules/index.ts b/modules/index.ts index d0ea5cd2..b5d4a28b 100644 --- a/modules/index.ts +++ b/modules/index.ts @@ -18,6 +18,7 @@ export type { DownloadErrorEvent, DownloadProgressEvent, DownloadStartedEvent, + StorageLocation, } from "./background-downloader"; // Background Downloader export { default as BackgroundDownloader } from "./background-downloader"; diff --git a/providers/Downloads/additionalDownloads.ts b/providers/Downloads/additionalDownloads.ts index eca28e0d..a15bf9d9 100644 --- a/providers/Downloads/additionalDownloads.ts +++ b/providers/Downloads/additionalDownloads.ts @@ -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 { 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 { 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; saveSeriesImageFn: (item: BaseItemDto) => Promise; + 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({ diff --git a/providers/Downloads/hooks/useDownloadOperations.ts b/providers/Downloads/hooks/useDownloadOperations.ts index f1e4c4ed..e426c2b8 100644 --- a/providers/Downloads/hooks/useDownloadOperations.ts +++ b/providers/Downloads/hooks/useDownloadOperations.ts @@ -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}`); diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index ee0e8625..4710969c 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -145,6 +145,7 @@ export type Settings = { marlinServerUrl?: string; openInVLC?: boolean; downloadQuality?: DownloadOption; + downloadStorageLocation?: string; defaultBitrate?: Bitrate; libraryOptions: LibraryOptions; defaultAudioLanguage: CultureDto | null; @@ -203,6 +204,7 @@ export const defaultValues: Settings = { marlinServerUrl: "", openInVLC: false, downloadQuality: DownloadOptions[0], + downloadStorageLocation: undefined, defaultBitrate: BITRATES[0], libraryOptions: { display: "list", diff --git a/utils/storage.ts b/utils/storage.ts new file mode 100644 index 00000000..8cc3104f --- /dev/null +++ b/utils/storage.ts @@ -0,0 +1,116 @@ +import { Paths } from "expo-file-system"; +import { Platform } from "react-native"; +import { BackgroundDownloader, type StorageLocation } from "@/modules"; + +let cachedStorageLocations: StorageLocation[] | null = null; + +/** + * 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(); + 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 { + 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 { + 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 { + 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}`; +}