mirror of
https://github.com/jellyfin/jellyfin-expo.git
synced 2024-11-23 05:59:39 +00:00
Remux downloads to mp4
This commit is contained in:
parent
9b3fdd9cc8
commit
c18d5cf063
40
App.js
40
App.js
@ -12,6 +12,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import AppLoading from 'expo-app-loading';
|
||||
import { Asset } from 'expo-asset';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import * as Font from 'expo-font';
|
||||
import * as ScreenOrientation from 'expo-screen-orientation';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
@ -26,6 +27,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import ThemeSwitcher from './components/ThemeSwitcher';
|
||||
import { useStores } from './hooks/useStores';
|
||||
import RootNavigator from './navigation/RootNavigator';
|
||||
import { ensurePathExists } from './utils/File';
|
||||
import StaticScriptLoader from './utils/StaticScriptLoader';
|
||||
|
||||
// Import i18n configuration
|
||||
@ -82,6 +84,44 @@ const App = observer(({ skipLoadingScreen }) => {
|
||||
updateScreenOrientation();
|
||||
}, [ rootStore.isFullscreen ]);
|
||||
|
||||
useEffect(() => {
|
||||
const downloadFile = async (download) => {
|
||||
console.debug('[App] downloading "%s"', download.filename);
|
||||
await ensurePathExists(download.localPath);
|
||||
|
||||
const url = download.getStreamUrl(rootStore.deviceId);
|
||||
|
||||
const resumable = FileSystem.createDownloadResumable(
|
||||
url.toString(),
|
||||
download.uri,
|
||||
{},
|
||||
(/*{ totalBytesWritten }*/) => {
|
||||
// FIXME: We should save the download progress in the model for display
|
||||
// but this needs throttling
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: The resumable should be saved to allow pausing/resuming downloads
|
||||
|
||||
try {
|
||||
download.isDownloading = true;
|
||||
await resumable.downloadAsync();
|
||||
download.isComplete = true;
|
||||
download.isDownloading = false;
|
||||
} catch (e) {
|
||||
console.error('[App] Download failed', e);
|
||||
download.isDownloading = false;
|
||||
}
|
||||
};
|
||||
|
||||
rootStore.downloadStore.downloads
|
||||
.forEach(download => {
|
||||
if (!download.isComplete && !download.isDownloading) {
|
||||
downloadFile(download);
|
||||
}
|
||||
});
|
||||
}, [ rootStore.deviceId, rootStore.downloadStore.downloads.size ]);
|
||||
|
||||
const loadImagesAsync = () => {
|
||||
const images = [
|
||||
require('./assets/images/splash.png'),
|
||||
|
@ -11,7 +11,7 @@ import { Button, ListItem } from 'react-native-elements';
|
||||
|
||||
import { getIconName } from '../utils/Icons';
|
||||
|
||||
const DownloadListItem = ({ item, index, onSelect, onShare, isEditMode = false, isSelected = false }) => (
|
||||
const DownloadListItem = ({ item, index, onSelect, onPlay, isEditMode = false, isSelected = false }) => (
|
||||
<ListItem
|
||||
topDivider={index === 0}
|
||||
bottomDivider
|
||||
@ -23,17 +23,18 @@ const DownloadListItem = ({ item, index, onSelect, onShare, isEditMode = false,
|
||||
/>
|
||||
}
|
||||
<ListItem.Content>
|
||||
<ListItem.Title>{item.title || item.fileName || item.itemId}</ListItem.Title>
|
||||
<ListItem.Title>{item.title}</ListItem.Title>
|
||||
<ListItem.Subtitle>{item.localFilename}</ListItem.Subtitle>
|
||||
</ListItem.Content>
|
||||
{item.isComplete ?
|
||||
<Button
|
||||
type='clear'
|
||||
icon={{
|
||||
name: getIconName('share-outline'),
|
||||
name: getIconName('play'),
|
||||
type: 'ionicon'
|
||||
}}
|
||||
disabled={isEditMode}
|
||||
onPress={() => onShare(item)}
|
||||
onPress={() => onPlay(item)}
|
||||
/> : <ActivityIndicator />
|
||||
}
|
||||
</ListItem>
|
||||
@ -43,7 +44,7 @@ DownloadListItem.propTypes = {
|
||||
item: PropTypes.object.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
onShare: PropTypes.func.isRequired,
|
||||
onPlay: PropTypes.func.isRequired,
|
||||
isEditMode: PropTypes.bool,
|
||||
isSelected: PropTypes.bool
|
||||
};
|
||||
|
@ -14,6 +14,7 @@ import { BackHandler, Platform } from 'react-native';
|
||||
|
||||
import MediaTypes from '../constants/MediaTypes';
|
||||
import { useStores } from '../hooks/useStores';
|
||||
import DownloadModel from '../models/DownloadModel';
|
||||
import { getAppName, getDeviceProfile, getSafeDeviceName } from '../utils/Device';
|
||||
import StaticScriptLoader from '../utils/StaticScriptLoader';
|
||||
import { openBrowser } from '../utils/WebBrowser';
|
||||
@ -91,11 +92,19 @@ true;
|
||||
break;
|
||||
case 'downloadFile':
|
||||
console.log('Download item', data);
|
||||
const url = new URL(data.item.url); // eslint-disable-line no-case-declarations
|
||||
rootStore.downloadStore.add({
|
||||
...data.item,
|
||||
apiKey: url.searchParams.get('api_key')
|
||||
});
|
||||
/* eslint-disable no-case-declarations */
|
||||
const url = new URL(data.item.url);
|
||||
const apiKey = url.searchParams.get('api_key');
|
||||
/* eslint-enable no-case-declarations */
|
||||
rootStore.downloadStore.add(new DownloadModel(
|
||||
data.item.itemId,
|
||||
data.item.serverId,
|
||||
server.urlString,
|
||||
apiKey,
|
||||
data.item.title,
|
||||
data.item.filename,
|
||||
data.item.url
|
||||
));
|
||||
break;
|
||||
case 'openUrl':
|
||||
console.log('Opening browser for external url', data.url);
|
||||
|
91
models/DownloadModel.ts
Normal file
91
models/DownloadModel.ts
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { computed, decorate, observable } from 'mobx';
|
||||
import { ignore } from 'mobx-sync-lite';
|
||||
|
||||
export default class DownloadModel {
|
||||
isComplete = false
|
||||
isDownloading = false
|
||||
isNew = true
|
||||
|
||||
apiKey: string
|
||||
itemId: string
|
||||
serverId: string
|
||||
serverUrl: string
|
||||
|
||||
title: string
|
||||
filename: string
|
||||
|
||||
downloadUrl: string
|
||||
|
||||
constructor(
|
||||
itemId: string,
|
||||
serverId: string,
|
||||
serverUrl: string,
|
||||
apiKey: string,
|
||||
title: string,
|
||||
filename: string,
|
||||
downloadUrl: string
|
||||
) {
|
||||
this.itemId = itemId;
|
||||
this.serverId = serverId;
|
||||
this.serverUrl = serverUrl;
|
||||
this.apiKey = apiKey;
|
||||
this.title = title;
|
||||
this.filename = filename;
|
||||
this.downloadUrl = downloadUrl;
|
||||
}
|
||||
|
||||
get key() {
|
||||
return `${this.serverId}_${this.itemId}`;
|
||||
}
|
||||
|
||||
get localFilename() {
|
||||
return this.filename.slice(0, this.filename.lastIndexOf('.')) + '.mp4';
|
||||
}
|
||||
|
||||
get localPath() {
|
||||
return `${FileSystem.documentDirectory}${this.serverId}/${this.itemId}/`;
|
||||
}
|
||||
|
||||
get uri() {
|
||||
return this.localPath + encodeURI(this.localFilename);
|
||||
}
|
||||
|
||||
getStreamUrl(deviceId: string, params?: Record<string, string>): URL {
|
||||
const streamParams = new URLSearchParams({
|
||||
deviceId,
|
||||
api_key: this.apiKey,
|
||||
// TODO: add mediaSourceId to support alternate media versions
|
||||
videoCodec: 'hevc,h264',
|
||||
audioCodec: 'aac,mp3,ac3,eac3,flac,alac',
|
||||
maxAudioChannels: '6',
|
||||
// subtitleCodec: 'srt,vtt',
|
||||
// subtitleMethod: 'Encode',
|
||||
...params
|
||||
});
|
||||
return new URL(`${this.serverUrl}Videos/${this.itemId}/stream.mp4?${streamParams.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
decorate(DownloadModel, {
|
||||
isComplete: observable,
|
||||
isDownloading: [ ignore, observable ],
|
||||
isNew: observable,
|
||||
apiKey: observable,
|
||||
itemId: observable,
|
||||
serverId: observable,
|
||||
serverUrl: observable,
|
||||
title: observable,
|
||||
filename: observable,
|
||||
downloadUrl: observable,
|
||||
key: computed,
|
||||
localFilename: computed,
|
||||
localPath: computed,
|
||||
uri: computed
|
||||
});
|
@ -77,7 +77,8 @@ const TabNavigator = observer(() => {
|
||||
component={DownloadScreen}
|
||||
options={{
|
||||
title: t('headings.downloads'),
|
||||
headerShown: true
|
||||
headerShown: true,
|
||||
tabBarBadge: rootStore.downloadStore.newDownloadCount > 0 ? rootStore.downloadStore.newDownloadCount : null
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
8
package-lock.json
generated
8
package-lock.json
generated
@ -9348,14 +9348,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"expo-sharing": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-10.0.3.tgz",
|
||||
"integrity": "sha512-ZiXmirRVznbrrc0ztfNj+IV9bzUaeMqubEJSrnl/lIBXPPQNSSvDehe7hmPf7c7tHH6q4ebR/hlExkNgjz93wA==",
|
||||
"requires": {
|
||||
"expo-modules-core": "~0.4.4"
|
||||
}
|
||||
},
|
||||
"expo-splash-screen": {
|
||||
"version": "0.13.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.13.5.tgz",
|
||||
|
@ -74,8 +74,7 @@
|
||||
"react-native-screens": "~3.8.0",
|
||||
"react-native-url-polyfill": "^1.3.0",
|
||||
"react-native-webview": "11.13.0",
|
||||
"uuid": "^8.3.2",
|
||||
"expo-sharing": "~10.0.3"
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
|
@ -4,75 +4,66 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { toJS, values } from 'mobx';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, FlatList, StyleSheet } from 'react-native';
|
||||
import { Button, ThemeContext } from 'react-native-elements';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import DownloadListItem from '../components/DownloadListItem';
|
||||
import MediaTypes from '../constants/MediaTypes';
|
||||
import { useStores } from '../hooks/useStores';
|
||||
|
||||
const getDownloadDir = download => `${FileSystem.documentDirectory}${download.serverId}/${download.itemId}/`;
|
||||
const getDownloadUri = download => getDownloadDir(download) + encodeURI(download.filename);
|
||||
|
||||
async function ensureDirExists(dir) {
|
||||
const info = await FileSystem.getInfoAsync(dir);
|
||||
if (!info.exists) {
|
||||
await FileSystem.makeDirectoryAsync(dir, { intermediates: true });
|
||||
}
|
||||
}
|
||||
|
||||
const DownloadScreen = observer(() => {
|
||||
const navigation = useNavigation();
|
||||
const { rootStore } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const [ isEditMode, setIsEditMode ] = useState(false);
|
||||
const [ resumables, setResumables ] = useState([]);
|
||||
const [ selectedItems, setSelectedItems ] = useState([]);
|
||||
|
||||
async function deleteItem(item) {
|
||||
// TODO: Add user messaging on errors
|
||||
try {
|
||||
await FileSystem.deleteAsync(getDownloadDir(item));
|
||||
rootStore.downloadStore.remove(rootStore.downloadStore.downloads.indexOf(item));
|
||||
} catch (e) {
|
||||
console.error('Failed to delete download', e);
|
||||
}
|
||||
}
|
||||
|
||||
function exitEditMode() {
|
||||
setIsEditMode(false);
|
||||
setSelectedItems([]);
|
||||
}
|
||||
|
||||
function onDeleteItems(items) {
|
||||
Alert.alert(
|
||||
'Delete Downloads',
|
||||
'These items will be permanently deleted from this device.',
|
||||
[
|
||||
{
|
||||
text: t('common.cancel'),
|
||||
onPress: exitEditMode
|
||||
},
|
||||
{
|
||||
text: `Delete ${items.length} Downloads`,
|
||||
onPress: async () => {
|
||||
await Promise.all(items.map(deleteItem));
|
||||
exitEditMode();
|
||||
},
|
||||
style: 'destructive'
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
async function deleteItem(download) {
|
||||
// TODO: Add user messaging on errors
|
||||
try {
|
||||
await FileSystem.deleteAsync(download.localPath);
|
||||
rootStore.downloadStore.downloads.delete(download.key);
|
||||
console.log('download "%s" deleted', download.title);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete download', e);
|
||||
}
|
||||
}
|
||||
|
||||
function onDeleteItems(downloads) {
|
||||
Alert.alert(
|
||||
'Delete Downloads',
|
||||
'These items will be permanently deleted from this device.',
|
||||
[
|
||||
{
|
||||
text: t('common.cancel'),
|
||||
onPress: exitEditMode
|
||||
},
|
||||
{
|
||||
text: `Delete ${downloads.length} Downloads`,
|
||||
onPress: async () => {
|
||||
await Promise.all(downloads.map(deleteItem));
|
||||
exitEditMode();
|
||||
},
|
||||
style: 'destructive'
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
navigation.setOptions({
|
||||
headerLeft: () => (
|
||||
isEditMode ?
|
||||
@ -98,47 +89,26 @@ const DownloadScreen = observer(() => {
|
||||
<Button
|
||||
title={t('common.edit')}
|
||||
type='clear'
|
||||
style={styles.rightButton}
|
||||
disabled={rootStore.downloadStore.downloads.size < 1}
|
||||
onPress={() => {
|
||||
setIsEditMode(true);
|
||||
}}
|
||||
style={styles.rightButton}
|
||||
/>
|
||||
)
|
||||
});
|
||||
}, [ navigation, isEditMode, selectedItems ]);
|
||||
}, [ navigation, isEditMode, selectedItems, rootStore.downloadStore.downloads ]);
|
||||
|
||||
async function downloadFile(download) {
|
||||
await ensureDirExists(getDownloadDir(download));
|
||||
|
||||
const url = download.url;
|
||||
const uri = getDownloadUri(download);
|
||||
|
||||
const resumable = FileSystem.createDownloadResumable(
|
||||
url,
|
||||
uri,
|
||||
{},
|
||||
// TODO: Show download progress in ui
|
||||
console.log
|
||||
);
|
||||
setResumables([ ...resumables, resumable ]);
|
||||
try {
|
||||
rootStore.downloadStore.update(download, { isDownloading: true });
|
||||
await resumable.downloadAsync();
|
||||
rootStore.downloadStore.update(download, {
|
||||
isDownloading: false,
|
||||
isComplete: true
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Download failed', e);
|
||||
rootStore.downloadStore.update(download, { isDownloading: false });
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
rootStore.downloadStore.downloads
|
||||
.filter(download => !download.isComplete && !download.isDownloading)
|
||||
.forEach(downloadFile);
|
||||
}, [ rootStore.downloadStore.downloads ]);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
rootStore.downloadStore.downloads
|
||||
.forEach(download => {
|
||||
if (download.isNew) {
|
||||
download.isNew = !download.isComplete;
|
||||
}
|
||||
});
|
||||
}, [ rootStore.downloadStore.downloads ])
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
@ -149,7 +119,8 @@ const DownloadScreen = observer(() => {
|
||||
edges={[ 'right', 'left' ]}
|
||||
>
|
||||
<FlatList
|
||||
data={[ ...rootStore.downloadStore.downloads ]}
|
||||
data={values(rootStore.downloadStore.downloads)}
|
||||
extraData={toJS(rootStore.downloadStore.downloads)}
|
||||
renderItem={({ item, index }) => (
|
||||
<DownloadListItem
|
||||
item={item}
|
||||
@ -163,14 +134,14 @@ const DownloadScreen = observer(() => {
|
||||
setSelectedItems([ ...selectedItems, item ]);
|
||||
}
|
||||
}}
|
||||
onShare={async () => {
|
||||
Sharing.shareAsync(
|
||||
await FileSystem.getContentUriAsync(getDownloadUri(item))
|
||||
);
|
||||
onPlay={async () => {
|
||||
item.isNew = false;
|
||||
rootStore.mediaStore.type = MediaTypes.Video;
|
||||
rootStore.mediaStore.uri = item.uri;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item, index) => `download-${index}-${item.serverId}-${item.itemId}`}
|
||||
keyExtractor={(item, index) => `download-${index}-${item.key}`}
|
||||
contentContainerStyle={styles.listContainer}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
|
@ -1,50 +0,0 @@
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { action, decorate, observable } from 'mobx';
|
||||
|
||||
export default class DownloadStore {
|
||||
downloads = []
|
||||
|
||||
add(download) {
|
||||
// Do not allow duplicate downloads
|
||||
if (!this.downloads.find(search => search.serverId === download.serverId && search.itemId === download.itemId)) {
|
||||
this.downloads = [ ...this.downloads, download ];
|
||||
}
|
||||
}
|
||||
|
||||
remove(index) {
|
||||
this.downloads = [
|
||||
...this.downloads.slice(0, index),
|
||||
...this.downloads.slice(index + 1)
|
||||
];
|
||||
}
|
||||
|
||||
update(original, changes = {}) {
|
||||
const index = this.downloads.findIndex(search => search.serverId === original.serverId && search.itemId === original.itemId);
|
||||
if (index > -1) {
|
||||
const updated = {
|
||||
...this.downloads[index],
|
||||
...changes
|
||||
};
|
||||
this.downloads[index] = updated;
|
||||
} else {
|
||||
console.warn('trying to update download missing from store', original);
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.downloads = [];
|
||||
}
|
||||
}
|
||||
|
||||
decorate(DownloadStore, {
|
||||
downloads: [ observable ],
|
||||
add: action,
|
||||
remove: action,
|
||||
update: action,
|
||||
reset: action
|
||||
});
|
67
stores/DownloadStore.ts
Normal file
67
stores/DownloadStore.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { action, autorun, computed, decorate, observable } from 'mobx';
|
||||
import { format } from 'mobx-sync-lite';
|
||||
|
||||
import DownloadModel from '../models/DownloadModel';
|
||||
|
||||
export default class DownloadStore {
|
||||
downloads = new Map<string, DownloadModel>();
|
||||
|
||||
constructor() {
|
||||
autorun(() => {
|
||||
console.debug('[DEBUG] DownloadStore', this.downloads);
|
||||
});
|
||||
}
|
||||
|
||||
get newDownloadCount() {
|
||||
return Array.from(this.downloads.values())
|
||||
.filter(d => d.isNew)
|
||||
.length;
|
||||
}
|
||||
|
||||
add(download: DownloadModel) {
|
||||
// Do not allow duplicate downloads
|
||||
if (!this.downloads.has(download.key)) {
|
||||
this.downloads.set(download.key, download);
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.downloads = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
decorate(DownloadStore, {
|
||||
downloads: [
|
||||
format(
|
||||
data => {
|
||||
const deserialized = new Map<string, DownloadModel>();
|
||||
Object.entries(data).forEach(([ key, dl ]) => {
|
||||
const model = new DownloadModel(
|
||||
dl.itemId,
|
||||
dl.serverId,
|
||||
dl.serverUrl,
|
||||
dl.apiKey,
|
||||
dl.title,
|
||||
dl.filename,
|
||||
dl.downloadUrl
|
||||
);
|
||||
model.isComplete = dl.isComplete;
|
||||
// isDownloading is ignored
|
||||
model.isNew = dl.isNew;
|
||||
deserialized.set(key, model);
|
||||
});
|
||||
return deserialized;
|
||||
}
|
||||
),
|
||||
observable
|
||||
],
|
||||
newDownloadCount: computed,
|
||||
add: action,
|
||||
reset: action
|
||||
});
|
@ -67,7 +67,7 @@ export default class SettingStore {
|
||||
isExperimentalNativeAudioPlayerEnabled = false
|
||||
|
||||
/**
|
||||
* Is experimental download support enabled
|
||||
* EXPERIMENTAL: Is download support enabled
|
||||
*/
|
||||
isExperimentalDownloadsEnabled = false;
|
||||
|
||||
|
18
utils/File.ts
Normal file
18
utils/File.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
/**
|
||||
* Checks if a path exists, and creates a directory (including missing intermediate directories) if it does not.
|
||||
* @param path A uri of a local directory
|
||||
*/
|
||||
export async function ensurePathExists(path: string) {
|
||||
const info = await FileSystem.getInfoAsync(path);
|
||||
if (!info.exists) {
|
||||
await FileSystem.makeDirectoryAsync(path, { intermediates: true });
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user