Remux downloads to mp4

This commit is contained in:
Bill Thornton 2022-01-31 15:16:15 -05:00
parent 9b3fdd9cc8
commit c18d5cf063
12 changed files with 296 additions and 157 deletions

40
App.js
View File

@ -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'),

View File

@ -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
};

View File

@ -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
View 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
});

View File

@ -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
View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

@ -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
View 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
});

View File

@ -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
View 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 });
}
}