diff --git a/App.js b/App.js
index 9c08aa289..850bbf8c0 100644
--- a/App.js
+++ b/App.js
@@ -17,8 +17,6 @@ import * as Font from 'expo-font';
import * as ScreenOrientation from 'expo-screen-orientation';
import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
-import { observer } from 'mobx-react-lite';
-import { AsyncTrunk } from 'mobx-sync-lite';
import PropTypes from 'prop-types';
import React, { useContext, useEffect, useState } from 'react';
import { Alert, useColorScheme } from 'react-native';
@@ -27,6 +25,8 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
import ThemeSwitcher from './components/ThemeSwitcher';
import { useStores } from './hooks/useStores';
+import DownloadModel from './models/DownloadModel';
+import ServerModel from './models/ServerModel';
import RootNavigator from './navigation/RootNavigator';
import { ensurePathExists } from './utils/File';
import StaticScriptLoader from './utils/StaticScriptLoader';
@@ -34,23 +34,85 @@ import StaticScriptLoader from './utils/StaticScriptLoader';
// Import i18n configuration
import './i18n';
-const App = observer(({ skipLoadingScreen }) => {
+const App = ({ skipLoadingScreen }) => {
const [ isSplashReady, setIsSplashReady ] = useState(false);
- const { rootStore } = useStores();
+ const { rootStore, downloadStore, settingStore, mediaStore, serverStore } = useStores();
const { theme } = useContext(ThemeContext);
- rootStore.settingStore.systemThemeId = useColorScheme();
+ // Using a hook here causes a render loop; what is the point of this setting?
+ // settingStore.set({systemThemeId: useColorScheme()});
+ settingStore.systemThemeId = useColorScheme();
SplashScreen.preventAutoHideAsync();
- const trunk = new AsyncTrunk(rootStore, {
- storage: AsyncStorage
- });
-
const hydrateStores = async () => {
- await trunk.init();
+ // TODO: In release n+2 from this point, remove this conversion code.
+ const mobx_store_value = await AsyncStorage.getItem('__mobx_sync__'); // Store will be null if it's not set
+
+ if (mobx_store_value !== null) {
+ console.info('Migrating mobx store to zustand');
+ const mobx_store = JSON.parse(mobx_store_value);
+
+ // Root Store
+ for (const key of Object.keys(mobx_store).filter(k => k.search('Store') === -1)) {
+ rootStore.set({ key: mobx_store[key] });
+ }
+
+ // MediaStore
+ for (const key of Object.keys(mobx_store.mediaStore)) {
+ mediaStore.set({ key: mobx_store.mediaStore[key] });
+ }
+
+ /**
+ * Server store & download store need some special treatment because they
+ * are not simple key-value pair stores. Each contains one key which is a
+ * list of Model objects that represent the contents of their respective
+ * stores.
+ *
+ * zustand requires a custom storage engine for these for proper
+ * serialization and deserialization (written in each storage's module),
+ * but this code is needed to get them over the hump from mobx to zustand.
+ */
+ // DownloadStore
+ const mobxDownloads = mobx_store.downloadStore.downloads;
+ const migratedDownloads = new Map();
+ if (Object.keys(mobxDownloads).length > 0) {
+ for (const [ key, value ] of Object.getEntries(mobxDownloads)) {
+ migratedDownloads.set(key, new DownloadModel(
+ value.itemId,
+ value.serverId,
+ value.serverUrl,
+ value.apiKey,
+ value.title,
+ value.fileName,
+ value.downloadUrl
+ ));
+ }
+ }
+ downloadStore.set({ downloads: migratedDownloads });
- rootStore.storeLoaded = true;
+ // ServerStore
+ const mobxServers = mobx_store.serverStore.servers;
+ const migratedServers = [];
+ if (Object.keys(mobxServers).length > 0) {
+ for (const item of mobxServers) {
+ migratedServers.push(new ServerModel(item.id, new URL(item.url), item.info));
+ }
+ }
+ serverStore.set({ servers: migratedServers });
+
+ // SettingStore
+ for (const key of Object.keys(mobx_store.settingStore)) {
+ console.info('SettingStore', key);
+ settingStore.set({ key: mobx_store.settingStore[key] });
+ }
+
+ // TODO: Confirm zustand has objects in async storage
+ // TODO: Remove mobx sync item from async storage
+ // AsyncStorage.removeItem('__mobx_sync__')
+ }
+
+ rootStore.set({ storeLoaded: true });
};
const loadImages = () => {
@@ -79,6 +141,7 @@ const App = observer(({ skipLoadingScreen }) => {
};
useEffect(() => {
+ // Set base app theme
// Hydrate mobx data stores
hydrateStores();
@@ -87,16 +150,16 @@ const App = observer(({ skipLoadingScreen }) => {
}, []);
useEffect(() => {
- console.info('rotation lock setting changed!', rootStore.settingStore.isRotationLockEnabled);
- if (rootStore.settingStore.isRotationLockEnabled) {
+ console.info('rotation lock setting changed!', settingStore.isRotationLockEnabled);
+ if (settingStore.isRotationLockEnabled) {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
} else {
ScreenOrientation.unlockAsync();
}
- }, [ rootStore.settingStore.isRotationLockEnabled ]);
+ }, [ settingStore.isRotationLockEnabled ]);
const updateScreenOrientation = async () => {
- if (rootStore.settingStore.isRotationLockEnabled) {
+ if (settingStore.isRotationLockEnabled) {
if (rootStore.isFullscreen) {
// Lock to landscape orientation
// For some reason video apps on iPhone use LANDSCAPE_RIGHT ¯\_(ツ)_/¯
@@ -163,13 +226,13 @@ const App = observer(({ skipLoadingScreen }) => {
});
};
- rootStore.downloadStore.downloads
+ downloadStore.downloads
.forEach(download => {
if (!download.isComplete && !download.isDownloading) {
downloadFile(download);
}
});
- }, [ rootStore.deviceId, rootStore.downloadStore.downloads.size ]);
+ }, [ rootStore.deviceId, downloadStore.downloads.size ]);
if (!(isSplashReady && rootStore.storeLoaded) && !skipLoadingScreen) {
return null;
@@ -177,20 +240,20 @@ const App = observer(({ skipLoadingScreen }) => {
return (
-
+
-
+
);
-});
+};
App.propTypes = {
skipLoadingScreen: PropTypes.bool
diff --git a/__mocks__/zustand.ts b/__mocks__/zustand.ts
new file mode 100644
index 000000000..52466b5f9
--- /dev/null
+++ b/__mocks__/zustand.ts
@@ -0,0 +1,65 @@
+// __mocks__/zustand.ts
+import { act } from '@testing-library/react';
+import type * as ZustandExportedTypes from 'zustand';
+export * from 'zustand';
+
+const { create: actualCreate, createStore: actualCreateStore } =
+ jest.requireActual('zustand');
+
+// a variable to hold reset functions for all stores declared in the app
+export const storeResetFns = new Set<() => void>();
+
+const createUncurried = (
+ stateCreator: ZustandExportedTypes.StateCreator
+) => {
+ const store = actualCreate(stateCreator);
+ const initialState = store.getInitialState();
+ storeResetFns.add(() => {
+ store.setState(initialState, true);
+ });
+ return store;
+};
+
+// when creating a store, we get its initial state, create a reset function and add it in the set
+export const create = ((
+ stateCreator: ZustandExportedTypes.StateCreator
+) => {
+ console.log('zustand create mock');
+
+ // to support curried version of create
+ return typeof stateCreator === 'function'
+ ? createUncurried(stateCreator)
+ : createUncurried;
+}) as typeof ZustandExportedTypes.create;
+
+const createStoreUncurried = (
+ stateCreator: ZustandExportedTypes.StateCreator
+) => {
+ const store = actualCreateStore(stateCreator);
+ const initialState = store.getInitialState();
+ storeResetFns.add(() => {
+ store.setState(initialState, true);
+ });
+ return store;
+};
+
+// when creating a store, we get its initial state, create a reset function and add it in the set
+export const createStore = ((
+ stateCreator: ZustandExportedTypes.StateCreator
+) => {
+ console.log('zustand createStore mock');
+
+ // to support curried version of createStore
+ return typeof stateCreator === 'function'
+ ? createStoreUncurried(stateCreator)
+ : createStoreUncurried;
+}) as typeof ZustandExportedTypes.createStore;
+
+// reset all stores after each test run
+afterEach(() => {
+ act(() => {
+ storeResetFns.forEach((resetFn) => {
+ resetFn();
+ });
+ });
+});
diff --git a/components/AudioPlayer.js b/components/AudioPlayer.js
index a5a478d54..5ed1aec92 100644
--- a/components/AudioPlayer.js
+++ b/components/AudioPlayer.js
@@ -5,15 +5,14 @@
*/
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av';
-import { observer } from 'mobx-react-lite';
import React, { useEffect, useState } from 'react';
import MediaTypes from '../constants/MediaTypes';
import { useStores } from '../hooks/useStores';
import { msToTicks } from '../utils/Time';
-const AudioPlayer = observer(() => {
- const { rootStore } = useStores();
+const AudioPlayer = () => {
+ const { mediaStore } = useStores();
const [ player, setPlayer ] = useState();
@@ -57,48 +56,50 @@ const AudioPlayer = observer(() => {
didJustFinish === undefined ||
isPlaying === undefined ||
positionMs === undefined ||
- rootStore.mediaStore.isFinished
+ mediaStore.isFinished
) {
return;
}
- rootStore.mediaStore.isFinished = didJustFinish;
- rootStore.mediaStore.isPlaying = isPlaying;
- rootStore.mediaStore.positionTicks = msToTicks(positionMs);
+ mediaStore.set({
+ isFinished: didJustFinish,
+ isPlaying: isPlaying,
+ positionTicks: msToTicks(positionMs)
+ });
});
setPlayer(sound);
}
};
- if (rootStore.mediaStore.type === MediaTypes.Audio) {
+ if (mediaStore.type === MediaTypes.Audio) {
createPlayer({
- uri: rootStore.mediaStore.uri,
- positionMillis: rootStore.mediaStore.positionMillis
+ uri: mediaStore.uri,
+ positionMillis: mediaStore.positionMillis
});
}
- }, [ rootStore.mediaStore.type, rootStore.mediaStore.uri ]);
+ }, [ mediaStore.type, mediaStore.uri ]);
// Update the play/pause state when the store indicates it should
useEffect(() => {
- if (rootStore.mediaStore.type === MediaTypes.Audio && rootStore.mediaStore.shouldPlayPause) {
- if (rootStore.mediaStore.isPlaying) {
+ if (mediaStore.type === MediaTypes.Audio && mediaStore.shouldPlayPause) {
+ if (mediaStore.isPlaying) {
player?.pauseAsync();
} else {
player?.playAsync();
}
- rootStore.mediaStore.shouldPlayPause = false;
+ mediaStore.set({ shouldPlayPause: false });
}
- }, [ rootStore.mediaStore.shouldPlayPause ]);
+ }, [ mediaStore.shouldPlayPause ]);
// Stop the player when the store indicates it should stop playback
useEffect(() => {
- if (rootStore.mediaStore.type === MediaTypes.Audio && rootStore.mediaStore.shouldStop) {
+ if (mediaStore.type === MediaTypes.Audio && mediaStore.shouldStop) {
player?.stopAsync();
player?.unloadAsync();
- rootStore.mediaStore.shouldStop = false;
+ mediaStore.set({ shouldStop: false });
}
- }, [ rootStore.mediaStore.shouldStop ]);
+ }, [ mediaStore.shouldStop ]);
return <>>;
-});
+};
export default AudioPlayer;
diff --git a/components/NativeShellWebView.js b/components/NativeShellWebView.js
index ac5317d05..566b239b8 100644
--- a/components/NativeShellWebView.js
+++ b/components/NativeShellWebView.js
@@ -7,8 +7,6 @@
import compareVersions from 'compare-versions';
import Constants from 'expo-constants';
import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake';
-import { action } from 'mobx';
-import { observer } from 'mobx-react-lite';
import React, { useState } from 'react';
import { BackHandler, Platform } from 'react-native';
@@ -21,15 +19,14 @@ import { openBrowser } from '../utils/WebBrowser';
import RefreshWebView from './RefreshWebView';
-const NativeShellWebView = observer(
- function NativeShellWebView(props, ref) {
- const { rootStore } = useStores();
- const [ isRefreshing, setIsRefreshing ] = useState(false);
+const NativeShellWebView = (props, ref) => {
+ const { rootStore, downloadStore, serverStore, mediaStore, settingStore } = useStores();
+ const [ isRefreshing, setIsRefreshing ] = useState(false);
- const server = rootStore.serverStore.servers[rootStore.settingStore.activeServer];
- const isPluginSupported = !!server.info?.Version && compareVersions.compare(server.info.Version, '10.7', '>=');
+ const server = serverStore.servers[settingStore.activeServer];
+ const isPluginSupported = !!server.info?.Version && compareVersions.compare(server.info.Version, '10.7', '>=');
- const injectedJavaScript = `
+ const injectedJavaScript = `
window.ExpoAppInfo = {
appName: '${getAppName()}',
appVersion: '${Constants.nativeAppVersion}',
@@ -39,12 +36,12 @@ window.ExpoAppInfo = {
window.ExpoAppSettings = {
isPluginSupported: ${isPluginSupported},
- isNativeVideoPlayerEnabled: ${rootStore.settingStore.isNativeVideoPlayerEnabled},
- isExperimentalNativeAudioPlayerEnabled: ${rootStore.settingStore.isExperimentalNativeAudioPlayerEnabled},
- isExperimentalDownloadsEnabled: ${rootStore.settingStore.isExperimentalDownloadsEnabled}
+ isNativeVideoPlayerEnabled: ${settingStore.isNativeVideoPlayerEnabled},
+ isExperimentalNativeAudioPlayerEnabled: ${settingStore.isExperimentalNativeAudioPlayerEnabled},
+ isExperimentalDownloadsEnabled: ${settingStore.isExperimentalDownloadsEnabled}
};
-window.ExpoVideoProfile = ${JSON.stringify(getDeviceProfile({ enableFmp4: rootStore.settingStore.isFmp4Enabled }))};
+window.ExpoVideoProfile = ${JSON.stringify(getDeviceProfile({ enableFmp4: settingStore.isFmp4Enabled }))};
function postExpoEvent(event, data) {
window.ReactNativeWebView.postMessage(JSON.stringify({
@@ -65,130 +62,131 @@ window.onerror = console.error;
true;
`;
- const onRefresh = () => {
- // Disable pull to refresh when in fullscreen
- if (rootStore.isFullscreen) return;
-
- // Stop media playback in native players
- rootStore.mediaStore.shouldStop = true;
-
- setIsRefreshing(true);
- ref.current?.reload();
- setIsRefreshing(false);
- };
-
- const onMessage = action(({ nativeEvent: state }) => {
- try {
- const { event, data } = JSON.parse(state.data);
- switch (event) {
- case 'AppHost.exit':
- BackHandler.exitApp();
- break;
- case 'enableFullscreen':
- rootStore.isFullscreen = true;
- break;
- case 'disableFullscreen':
- rootStore.isFullscreen = false;
- break;
- case 'downloadFile':
- console.log('Download item', data);
- /* 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);
- openBrowser(data.url);
- break;
- case 'updateMediaSession':
- // Keep the screen awake when music is playing
- if (rootStore.settingStore.isScreenLockEnabled) {
- activateKeepAwake();
- }
- break;
- case 'hideMediaSession':
- // When music session stops disable keep awake
- if (rootStore.settingStore.isScreenLockEnabled) {
- deactivateKeepAwake();
- }
- break;
- case 'ExpoAudioPlayer.play':
- case 'ExpoVideoPlayer.play':
- rootStore.mediaStore.type = event === 'ExpoAudioPlayer.play' ? MediaTypes.Audio : MediaTypes.Video;
- rootStore.mediaStore.uri = data.url;
- rootStore.mediaStore.backdropUri = data.backdropUrl;
- rootStore.mediaStore.isFinished = false;
- rootStore.mediaStore.positionTicks = data.playerStartPositionTicks;
- break;
- case 'ExpoAudioPlayer.playPause':
- case 'ExpoVideoPlayer.playPause':
- rootStore.mediaStore.shouldPlayPause = true;
- break;
- case 'ExpoAudioPlayer.stop':
- case 'ExpoVideoPlayer.stop':
- rootStore.mediaStore.shouldStop = true;
- break;
- case 'console.debug':
- // console.debug('[Browser Console]', data);
- break;
- case 'console.error':
- console.error('[Browser Console]', data);
- break;
- case 'console.info':
- // console.info('[Browser Console]', data);
- break;
- case 'console.log':
- // console.log('[Browser Console]', data);
- break;
- case 'console.warn':
- console.warn('[Browser Console]', data);
- break;
- default:
- console.debug('[HomeScreen.onMessage]', event, data);
- }
- } catch (ex) {
- console.warn('Exception handling message', state.data);
+ const onRefresh = () => {
+ // Disable pull to refresh when in fullscreen
+ if (rootStore.isFullscreen) return;
+
+ // Stop media playback in native players
+ mediaStore.set({ shouldStop: true });
+
+ setIsRefreshing(true);
+ ref.current?.reload();
+ setIsRefreshing(false);
+ };
+
+ const onMessage = ({ nativeEvent: state }) => {
+ try {
+ const { event, data } = JSON.parse(state.data);
+ switch (event) {
+ case 'AppHost.exit':
+ BackHandler.exitApp();
+ break;
+ case 'enableFullscreen':
+ rootStore.set({ isFullscreen: true });
+ break;
+ case 'disableFullscreen':
+ rootStore.set({ isFullscreen: false });
+ break;
+ case 'downloadFile':
+ console.log('Download item', data);
+ /* eslint-disable no-case-declarations */
+ const url = new URL(data.item.url);
+ const apiKey = url.searchParams.get('api_key');
+ /* eslint-enable no-case-declarations */
+ 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);
+ openBrowser(data.url);
+ break;
+ case 'updateMediaSession':
+ // Keep the screen awake when music is playing
+ if (settingStore.isScreenLockEnabled) {
+ activateKeepAwake();
+ }
+ break;
+ case 'hideMediaSession':
+ // When music session stops disable keep awake
+ if (settingStore.isScreenLockEnabled) {
+ deactivateKeepAwake();
+ }
+ break;
+ case 'ExpoAudioPlayer.play':
+ case 'ExpoVideoPlayer.play':
+ mediaStore.set({
+ type: event === 'ExpoAudioPlayer.play' ? MediaTypes.Audio : MediaTypes.Video,
+ uri: data.url,
+ backdropUri: data.backdropUrl,
+ isFinished: false,
+ positionTicks: data.playerStartPositionTicks
+ });
+ break;
+ case 'ExpoAudioPlayer.playPause':
+ case 'ExpoVideoPlayer.playPause':
+ mediaStore.set({ shouldPlayPause: true });
+ break;
+ case 'ExpoAudioPlayer.stop':
+ case 'ExpoVideoPlayer.stop':
+ mediaStore.set({ shouldStop: true });
+ break;
+ case 'console.debug':
+ // console.debug('[Browser Console]', data);
+ break;
+ case 'console.error':
+ console.error('[Browser Console]', data);
+ break;
+ case 'console.info':
+ // console.info('[Browser Console]', data);
+ break;
+ case 'console.log':
+ // console.log('[Browser Console]', data);
+ break;
+ case 'console.warn':
+ console.warn('[Browser Console]', data);
+ break;
+ default:
+ console.debug('[HomeScreen.onMessage]', event, data);
}
- });
-
- return (
-
- );
- }, { forwardRef: true }
-);
-
-export default NativeShellWebView;
+ } catch (ex) {
+ console.warn('Exception handling message', state.data);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default React.forwardRef(NativeShellWebView);
diff --git a/components/RefreshWebView.js b/components/RefreshWebView.js
index e79531a84..86098cfef 100644
--- a/components/RefreshWebView.js
+++ b/components/RefreshWebView.js
@@ -3,51 +3,48 @@
* 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 { observer } from 'mobx-react-lite';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
import { Dimensions, RefreshControl, StyleSheet } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import { WebView } from 'react-native-webview';
-const RefreshWebView = observer(
- function RefreshWebView({ isRefreshing, onRefresh, refreshControlProps, ...webViewProps }, ref) {
- const [ height, setHeight ] = useState(Dimensions.get('screen').height);
- const [ isEnabled, setEnabled ] = useState(typeof onRefresh === 'function');
+const RefreshWebView = function RefreshWebView({ isRefreshing, onRefresh, refreshControlProps, ...webViewProps }, ref) {
+ const [ height, setHeight ] = useState(Dimensions.get('screen').height);
+ const [ isEnabled, setEnabled ] = useState(typeof onRefresh === 'function');
- return (
- setHeight(e.nativeEvent.layout.height)}
- refreshControl={
-
- }
- showsVerticalScrollIndicator={false}
- showsHorizontalScrollIndicator={false}
- style={styles.view}>
-
- setEnabled(
- typeof onRefresh === 'function' &&
- e.nativeEvent.contentOffset.y === 0
- )
- }
- style={{
- ...styles.view,
- height,
- ...webViewProps.style
- }}
+ return (
+ setHeight(e.nativeEvent.layout.height)}
+ refreshControl={
+
-
- );
- }, { forwardRef: true }
-);
+ }
+ showsVerticalScrollIndicator={false}
+ showsHorizontalScrollIndicator={false}
+ style={styles.view}>
+
+ setEnabled(
+ typeof onRefresh === 'function' &&
+ e.nativeEvent.contentOffset.y === 0
+ )
+ }
+ style={{
+ ...styles.view,
+ height,
+ ...webViewProps.style
+ }}
+ />
+
+ );
+};
RefreshWebView.propTypes = {
isRefreshing: PropTypes.bool.isRequired,
@@ -62,4 +59,4 @@ const styles = StyleSheet.create({
}
});
-export default RefreshWebView;
+export default React.forwardRef(RefreshWebView);
diff --git a/components/ServerInput.js b/components/ServerInput.js
index 5e7a71500..f5f24c1e4 100644
--- a/components/ServerInput.js
+++ b/components/ServerInput.js
@@ -4,8 +4,6 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { useNavigation } from '@react-navigation/native';
-import { action } from 'mobx';
-import { observer } from 'mobx-react-lite';
import PropTypes from 'prop-types';
import React, { useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -19,118 +17,116 @@ import { parseUrl, validateServer } from '../utils/ServerValidator';
const sanitizeHost = (url = '') => url.trim();
-const ServerInput = observer(
- // FIXME: eslint fails to parse the propTypes properly here
- function ServerInput({
- onError = () => { /* noop */ }, // eslint-disable-line react/prop-types
- onSuccess = () => { /* noop */ }, // eslint-disable-line react/prop-types
- ...props
- }, ref) {
- const [ host, setHost ] = useState('');
- const [ isValidating, setIsValidating ] = useState(false);
- const [ isValid, setIsValid ] = useState(true);
- const [ validationMessage, setValidationMessage ] = useState('');
+// FIXME: eslint fails to parse the propTypes properly here
+const ServerInput = function ServerInput({
+ onError = () => { /* noop */ }, // eslint-disable-line react/prop-types
+ onSuccess = () => { /* noop */ }, // eslint-disable-line react/prop-types
+ ...props
+}, ref) {
+ const [ host, setHost ] = useState('');
+ const [ isValidating, setIsValidating ] = useState(false);
+ const [ isValid, setIsValid ] = useState(true);
+ const [ validationMessage, setValidationMessage ] = useState('');
- const { rootStore } = useStores();
- const navigation = useNavigation();
- const { t } = useTranslation();
- const { theme } = useContext(ThemeContext);
+ const { serverStore, settingStore } = useStores();
+ const navigation = useNavigation();
+ const { t } = useTranslation();
+ const { theme } = useContext(ThemeContext);
- const onAddServer = action(async () => {
- console.log('add server', host);
- if (!host) {
- setIsValid(false);
- setValidationMessage(t('addServer.validation.empty'));
- onError();
- return;
- }
+ const onAddServer = async () => {
+ console.log('add server', host);
+ if (!host) {
+ setIsValid(false);
+ setValidationMessage(t('addServer.validation.empty'));
+ onError();
+ return;
+ }
- setIsValidating(true);
- setIsValid(true);
- setValidationMessage('');
+ setIsValidating(true);
+ setIsValid(true);
+ setValidationMessage('');
- // Parse the entered url
- let url;
- try {
- url = parseUrl(host);
- console.log('parsed url', url);
- } catch (err) {
- console.info(err);
- setIsValidating(false);
- setIsValid(false);
- setValidationMessage(t('addServer.validation.invalid'));
- onError();
- return;
- }
+ // Parse the entered url
+ let url;
+ try {
+ url = parseUrl(host);
+ console.log('parsed url', url);
+ } catch (err) {
+ console.info(err);
+ setIsValidating(false);
+ setIsValid(false);
+ setValidationMessage(t('addServer.validation.invalid'));
+ onError();
+ return;
+ }
- // Validate the server is available
- const validation = await validateServer({ url });
- console.log(`Server is ${validation.isValid ? '' : 'not '}valid`);
- if (!validation.isValid) {
- const message = validation.message || 'invalid';
- setIsValidating(false);
- setIsValid(validation.isValid);
- setValidationMessage(t([ `addServer.validation.${message}`, 'addServer.validation.invalid' ]));
- onError();
- return;
- }
+ // Validate the server is available
+ const validation = await validateServer({ url });
+ console.log(`Server is ${validation.isValid ? '' : 'not '}valid`);
+ if (!validation.isValid) {
+ const message = validation.message || 'invalid';
+ setIsValidating(false);
+ setIsValid(validation.isValid);
+ setValidationMessage(t([ `addServer.validation.${message}`, 'addServer.validation.invalid' ]));
+ onError();
+ return;
+ }
- // Save the server details
- rootStore.serverStore.addServer({ url });
- rootStore.settingStore.activeServer = rootStore.serverStore.servers.length - 1;
- // Call the success callback
- onSuccess();
+ // Save the server details
+ serverStore.addServer({ url });
+ settingStore.set({ activeServer: serverStore.servers.length - 1 });
+ // Call the success callback
+ onSuccess();
- // Navigate to the main screen
- navigation.replace(
- Screens.MainScreen,
- {
- screen: Screens.HomeTab,
- params: {
- screen: Screens.HomeScreen,
- params: { activeServer: rootStore.settingStore.activeServer }
- }
+ // Navigate to the main screen
+ navigation.replace(
+ Screens.MainScreen,
+ {
+ screen: Screens.HomeTab,
+ params: {
+ screen: Screens.HomeScreen,
+ params: { activeServer: settingStore.activeServer }
}
- );
- });
-
- return (
- : null}
- selectionColor={theme.colors.primary}
- autoCapitalize='none'
- autoCorrect={false}
- autoCompleteType='off'
- autoFocus={true}
- keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
- returnKeyType='go'
- textContentType='URL'
- editable={!isValidating}
- value={host}
- errorMessage={isValid ? null : validationMessage}
- onChangeText={text => setHost(sanitizeHost(text))}
- onSubmitEditing={() => onAddServer()}
- {...props}
- />
+ }
);
- }, { forwardRef: true }
-);
+ };
+
+ return (
+ : null}
+ selectionColor={theme.colors.primary}
+ autoCapitalize='none'
+ autoCorrect={false}
+ autoCompleteType='off'
+ autoFocus={true}
+ keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
+ returnKeyType='go'
+ textContentType='URL'
+ editable={!isValidating}
+ value={host}
+ errorMessage={isValid ? null : validationMessage}
+ onChangeText={text => setHost(sanitizeHost(text))}
+ onSubmitEditing={() => onAddServer()}
+ {...props}
+ />
+ );
+};
ServerInput.propTypes = {
onError: PropTypes.func,
@@ -149,4 +145,4 @@ const styles = StyleSheet.create({
}
});
-export default ServerInput;
+export default React.forwardRef(ServerInput);
diff --git a/components/ThemeSwitcher.js b/components/ThemeSwitcher.js
index 511dc4b56..39088fc8f 100644
--- a/components/ThemeSwitcher.js
+++ b/components/ThemeSwitcher.js
@@ -15,13 +15,13 @@ import { useStores } from '../hooks/useStores';
* replaceTheme when the theme value in the store changes.
*/
const ThemeSwitcher = () => {
- const { rootStore } = useStores();
+ const { settingStore } = useStores();
const { replaceTheme } = useContext(ThemeContext);
useEffect(() => {
console.info('theme changed!');
- replaceTheme(rootStore.settingStore.theme.Elements);
- }, [ rootStore.settingStore.theme ]);
+ replaceTheme(settingStore.getTheme().Elements);
+ }, [ settingStore.getTheme() ]);
return <>>;
};
diff --git a/components/VideoPlayer.js b/components/VideoPlayer.js
index f8d2a9e95..9e5649553 100644
--- a/components/VideoPlayer.js
+++ b/components/VideoPlayer.js
@@ -4,7 +4,6 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Audio, InterruptionModeAndroid, InterruptionModeIOS, Video, VideoFullscreenUpdate } from 'expo-av';
-import { observer } from 'mobx-react-lite';
import React, { useEffect, useRef, useState } from 'react';
import { Alert } from 'react-native';
@@ -12,8 +11,8 @@ import MediaTypes from '../constants/MediaTypes';
import { useStores } from '../hooks/useStores';
import { msToTicks } from '../utils/Time';
-const VideoPlayer = observer(() => {
- const { rootStore } = useStores();
+const VideoPlayer = () => {
+ const { rootStore, mediaStore } = useStores();
const player = useRef(null);
// Local player fullscreen state
@@ -31,37 +30,37 @@ const VideoPlayer = observer(() => {
// Update the player when media type or uri changes
useEffect(() => {
- if (rootStore.mediaStore.type === MediaTypes.Video) {
- rootStore.didPlayerCloseManually = true;
+ if (mediaStore.type === MediaTypes.Video) {
+ rootStore.set({ didPlayerCloseManually: true });
player.current?.loadAsync({
- uri: rootStore.mediaStore.uri
+ uri: mediaStore.uri
}, {
- positionMillis: rootStore.mediaStore.positionMillis,
+ positionMillis: mediaStore.positionMillis,
shouldPlay: true
});
}
- }, [ rootStore.mediaStore.type, rootStore.mediaStore.uri ]);
+ }, [ mediaStore.type, mediaStore.uri ]);
// Update the play/pause state when the store indicates it should
useEffect(() => {
- if (rootStore.mediaStore.type === MediaTypes.Video && rootStore.mediaStore.shouldPlayPause) {
- if (rootStore.mediaStore.isPlaying) {
+ if (mediaStore.type === MediaTypes.Video && mediaStore.shouldPlayPause) {
+ if (mediaStore.isPlaying) {
player.current?.pauseAsync();
} else {
player.current?.playAsync();
}
- rootStore.mediaStore.shouldPlayPause = false;
+ mediaStore.set({ shouldPlayPause: false });
}
- }, [ rootStore.mediaStore.shouldPlayPause ]);
+ }, [ mediaStore.shouldPlayPause ]);
// Close the player when the store indicates it should stop playback
useEffect(() => {
- if (rootStore.mediaStore.type === MediaTypes.Video && rootStore.mediaStore.shouldStop) {
- rootStore.didPlayerCloseManually = false;
+ if (mediaStore.type === MediaTypes.Video && mediaStore.shouldStop) {
+ rootStore.set({ didPlayerCloseManually: false });
closeFullscreen();
- rootStore.mediaStore.shouldStop = false;
+ mediaStore.set({ shouldStop: false });
}
- }, [ rootStore.mediaStore.shouldStop ]);
+ }, [ mediaStore.shouldStop ]);
const openFullscreen = () => {
if (!isPresenting) {
@@ -86,24 +85,26 @@ const VideoPlayer = observer(() => {