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(() => {