Fix eslint errors

This commit is contained in:
Bill Thornton 2020-07-21 23:37:43 -04:00
parent f3a3647c79
commit 7c4c7329be
28 changed files with 1249 additions and 1249 deletions

156
App.js
View File

@ -30,99 +30,99 @@ import Theme from './utils/Theme';
import './i18n'; import './i18n';
const App = observer(({ skipLoadingScreen }) => { const App = observer(({ skipLoadingScreen }) => {
const [isSplashReady, setIsSplashReady] = useState(false); const [isSplashReady, setIsSplashReady] = useState(false);
const { rootStore } = useStores(); const { rootStore } = useStores();
const trunk = new AsyncTrunk(rootStore, { const trunk = new AsyncTrunk(rootStore, {
storage: AsyncStorage storage: AsyncStorage
}); });
const hydrateStores = async () => { const hydrateStores = async () => {
// Migrate servers and settings // Migrate servers and settings
// TODO: Remove this for next release // TODO: Remove this for next release
const servers = await CachingStorage.getInstance().getItem(StorageKeys.Servers); const servers = await CachingStorage.getInstance().getItem(StorageKeys.Servers);
if (servers) { if (servers) {
const activeServer = await CachingStorage.getInstance().getItem(StorageKeys.ActiveServer) || 0; const activeServer = await CachingStorage.getInstance().getItem(StorageKeys.ActiveServer) || 0;
// Initialize the store with the existing servers and settings // Initialize the store with the existing servers and settings
await trunk.init({ await trunk.init({
serverStore: { servers }, serverStore: { servers },
settingStore: { activeServer } settingStore: { activeServer }
}); });
// Remove old data values // Remove old data values
AsyncStorage.multiRemove(Object.values(StorageKeys)); AsyncStorage.multiRemove(Object.values(StorageKeys));
} else { } else {
// No servers saved in the old method, initialize normally // No servers saved in the old method, initialize normally
await trunk.init(); await trunk.init();
} }
rootStore.storeLoaded = true; rootStore.storeLoaded = true;
if (typeof rootStore.settingStore.isRotationEnabled === 'undefined') { if (typeof rootStore.settingStore.isRotationEnabled === 'undefined') {
rootStore.settingStore.isRotationEnabled = Platform.OS === 'ios' && !Platform.isPad; rootStore.settingStore.isRotationEnabled = Platform.OS === 'ios' && !Platform.isPad;
console.info('Initializing rotation lock setting', rootStore.settingStore.isRotationEnabled); console.info('Initializing rotation lock setting', rootStore.settingStore.isRotationEnabled);
} }
}; };
useEffect(() => { useEffect(() => {
// Hydrate mobx data stores // Hydrate mobx data stores
hydrateStores(); hydrateStores();
}, []); }, []);
useEffect(() => { useEffect(() => {
console.info('rotation lock setting changed!', rootStore.settingStore.isRotationEnabled); console.info('rotation lock setting changed!', rootStore.settingStore.isRotationEnabled);
if (rootStore.settingStore.isRotationEnabled) { if (rootStore.settingStore.isRotationEnabled) {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
} else { } else {
ScreenOrientation.unlockAsync(); ScreenOrientation.unlockAsync();
} }
}, [rootStore.settingStore.isRotationEnabled]); }, [rootStore.settingStore.isRotationEnabled]);
const loadImagesAsync = () => { const loadImagesAsync = () => {
const images = [ const images = [
require('./assets/images/splash.png'), require('./assets/images/splash.png'),
require('./assets/images/logowhite.png') require('./assets/images/logowhite.png')
]; ];
return images.map(image => Asset.fromModule(image).downloadAsync()); return images.map(image => Asset.fromModule(image).downloadAsync());
}; };
const loadResourcesAsync = async () => { const loadResourcesAsync = async () => {
return Promise.all([ return Promise.all([
Font.loadAsync({ Font.loadAsync({
// This is the font that we are using for our tab bar // This is the font that we are using for our tab bar
...Ionicons.font ...Ionicons.font
}), }),
...loadImagesAsync() ...loadImagesAsync()
]); ]);
}; };
if (!isSplashReady && !skipLoadingScreen) { if (!isSplashReady && !skipLoadingScreen) {
return ( return (
<AppLoading <AppLoading
startAsync={loadResourcesAsync} startAsync={loadResourcesAsync}
onError={console.warn} onError={console.warn}
onFinish={() => setIsSplashReady(true)} onFinish={() => setIsSplashReady(true)}
autoHideSplash={false} autoHideSplash={false}
/> />
); );
} }
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<ThemeProvider theme={Theme}> <ThemeProvider theme={Theme}>
<StatusBar <StatusBar
style="light" style="light"
backgroundColor={Colors.headerBackgroundColor} backgroundColor={Colors.headerBackgroundColor}
/> />
<AppNavigator /> <AppNavigator />
</ThemeProvider> </ThemeProvider>
</SafeAreaProvider> </SafeAreaProvider>
); );
}); });
App.propTypes = { App.propTypes = {
skipLoadingScreen: PropTypes.bool skipLoadingScreen: PropTypes.bool
}; };
export default App; export default App;

View File

@ -4,8 +4,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
module.exports = function(api) { module.exports = function(api) {
api.cache(true); api.cache(true);
return { return {
presets: ['babel-preset-expo'] presets: ['babel-preset-expo']
}; };
}; };

View File

@ -12,25 +12,25 @@ import { useTranslation } from 'react-i18next';
import { getAppName } from '../utils/Device'; import { getAppName } from '../utils/Device';
const AppInfoFooter = () => { const AppInfoFooter = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.text}>{`${getAppName()}`}</Text> <Text style={styles.text}>{`${getAppName()}`}</Text>
<Text style={styles.text}>{`${Constants.nativeAppVersion} (${Constants.nativeBuildVersion})`}</Text> <Text style={styles.text}>{`${Constants.nativeAppVersion} (${Constants.nativeBuildVersion})`}</Text>
<Text style={styles.text}>{t('settings.expoVersion', { version: Constants.expoVersion })}</Text> <Text style={styles.text}>{t('settings.expoVersion', { version: Constants.expoVersion })}</Text>
</View> </View>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
margin: 15 margin: 15
}, },
text: { text: {
color: colors.grey4, color: colors.grey4,
fontSize: 15 fontSize: 15
} }
}); });
export default AppInfoFooter; export default AppInfoFooter;

View File

@ -10,25 +10,25 @@ import PropTypes from 'prop-types';
import { openBrowser } from '../utils/WebBrowser'; import { openBrowser } from '../utils/WebBrowser';
const BrowserListItem = ({item, index}) => ( const BrowserListItem = ({item, index}) => (
<ListItem <ListItem
title={item.name} title={item.name}
leftIcon={item.icon} leftIcon={item.icon}
topDivider={index === 0} topDivider={index === 0}
bottomDivider bottomDivider
chevron chevron
onPress={() => { onPress={() => {
openBrowser(item.url); openBrowser(item.url);
}} }}
/> />
); );
BrowserListItem.propTypes = { BrowserListItem.propTypes = {
item: PropTypes.shape({ item: PropTypes.shape({
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired, icon: PropTypes.string.isRequired,
url: PropTypes.string.isRequired url: PropTypes.string.isRequired
}).isRequired, }).isRequired,
index: PropTypes.number.isRequired index: PropTypes.number.isRequired
}; };
export default BrowserListItem; export default BrowserListItem;

View File

@ -9,30 +9,30 @@ import { Button, ListItem } from 'react-native-elements';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
const ButtonListItem = ({item, index}) => ( const ButtonListItem = ({item, index}) => (
<ListItem <ListItem
title={ title={
<Button <Button
{...item} {...item}
type='clear' type='clear'
buttonStyle={{ ...styles.button, ...item.buttonStyle }} buttonStyle={{ ...styles.button, ...item.buttonStyle }}
titleStyle={{ ...styles.title, ...item.titleStyle }} titleStyle={{ ...styles.title, ...item.titleStyle }}
/> />
} }
topDivider={index === 0} topDivider={index === 0}
bottomDivider bottomDivider
/> />
); );
ButtonListItem.propTypes = { ButtonListItem.propTypes = {
item: PropTypes.object.isRequired, item: PropTypes.object.isRequired,
index: PropTypes.number.isRequired index: PropTypes.number.isRequired
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
button: { button: {
justifyContent: 'flex-start', justifyContent: 'flex-start',
padding: 0 padding: 0
} }
}); });
export default ButtonListItem; export default ButtonListItem;

View File

@ -18,127 +18,127 @@ import JellyfinValidator from '../utils/JellyfinValidator';
const sanitizeHost = (url = '') => url.trim(); const sanitizeHost = (url = '') => url.trim();
const ServerInput = observer(class ServerInput extends React.Component { const ServerInput = observer(class ServerInput extends React.Component {
static propTypes = { static propTypes = {
navigation: PropTypes.object.isRequired, navigation: PropTypes.object.isRequired,
rootStore: PropTypes.object.isRequired, rootStore: PropTypes.object.isRequired,
t: PropTypes.func.isRequired, t: PropTypes.func.isRequired,
onSuccess: PropTypes.func, onSuccess: PropTypes.func,
successScreen: PropTypes.string successScreen: PropTypes.string
} }
state = { state = {
host: '', host: '',
isValidating: false, isValidating: false,
isValid: true, isValid: true,
validationMessage: '' validationMessage: ''
} }
async onAddServer() { async onAddServer() {
const { host } = this.state; const { host } = this.state;
console.log('add server', host); console.log('add server', host);
if (host) { if (host) {
this.setState({ this.setState({
isValidating: true, isValidating: true,
isValid: true, isValid: true,
validationMessage: '' validationMessage: ''
}); });
// Parse the entered url // Parse the entered url
let url; let url;
try { try {
url = JellyfinValidator.parseUrl(host); url = JellyfinValidator.parseUrl(host);
console.log('parsed url', url); console.log('parsed url', url);
} catch (err) { } catch (err) {
console.info(err); console.info(err);
this.setState({ this.setState({
isValidating: false, isValidating: false,
isValid: false, isValid: false,
validationMessage: this.props.t('addServer.validation.invalid') validationMessage: this.props.t('addServer.validation.invalid')
}); });
return; return;
} }
// Validate the server is available // Validate the server is available
const validation = await JellyfinValidator.validate({ url }); const validation = await JellyfinValidator.validate({ url });
console.log(`Server is ${validation.isValid ? '' : 'not '}valid`); console.log(`Server is ${validation.isValid ? '' : 'not '}valid`);
if (!validation.isValid) { if (!validation.isValid) {
const message = validation.message || 'invalid'; const message = validation.message || 'invalid';
this.setState({ this.setState({
isValidating: false, isValidating: false,
isValid: validation.isValid, isValid: validation.isValid,
validationMessage: this.props.t([`addServer.validation.${message}`, 'addServer.validation.invalid']) validationMessage: this.props.t([`addServer.validation.${message}`, 'addServer.validation.invalid'])
}); });
return; return;
} }
// Save the server details // Save the server details
this.props.rootStore.serverStore.addServer({ url }); this.props.rootStore.serverStore.addServer({ url });
this.props.rootStore.settingStore.activeServer = this.props.rootStore.serverStore.servers.length - 1; this.props.rootStore.settingStore.activeServer = this.props.rootStore.serverStore.servers.length - 1;
// Call the success callback if present // Call the success callback if present
if (this.props.onSuccess) { if (this.props.onSuccess) {
this.props.onSuccess(); this.props.onSuccess();
} }
// Navigate to the main screen // Navigate to the main screen
this.props.navigation.replace( this.props.navigation.replace(
'Main', 'Main',
{ {
screen: this.props.successScreen || 'Home', screen: this.props.successScreen || 'Home',
params: { activeServer: this.props.rootStore.settingStore.activeServer } params: { activeServer: this.props.rootStore.settingStore.activeServer }
} }
); );
} else { } else {
this.setState({ this.setState({
isValid: false, isValid: false,
validationMessage: this.props.t('addServer.validation.empty') validationMessage: this.props.t('addServer.validation.empty')
}); });
} }
} }
render() { render() {
return ( return (
<Input <Input
inputContainerStyle={styles.inputContainerStyle} inputContainerStyle={styles.inputContainerStyle}
leftIcon={{ leftIcon={{
name: getIconName('globe'), name: getIconName('globe'),
type: 'ionicon' type: 'ionicon'
}} }}
labelStyle={{ labelStyle={{
color: colors.grey4 color: colors.grey4
}} }}
placeholderTextColor={colors.grey3} placeholderTextColor={colors.grey3}
rightIcon={this.state.isValidating ? <ActivityIndicator /> : null} rightIcon={this.state.isValidating ? <ActivityIndicator /> : null}
selectionColor={Colors.tintColor} selectionColor={Colors.tintColor}
autoCapitalize='none' autoCapitalize='none'
autoCorrect={false} autoCorrect={false}
autoCompleteType='off' autoCompleteType='off'
autoFocus={true} autoFocus={true}
keyboardType={Platform.OS === 'ios' ? 'url' : 'default'} keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
returnKeyType='go' returnKeyType='go'
textContentType='URL' textContentType='URL'
editable={!this.state.isValidating} editable={!this.state.isValidating}
value={this.state.host} value={this.state.host}
errorMessage={this.state.isValid ? null : this.state.validationMessage} errorMessage={this.state.isValid ? null : this.state.validationMessage}
onChangeText={text => this.setState({ host: sanitizeHost(text) })} onChangeText={text => this.setState({ host: sanitizeHost(text) })}
onSubmitEditing={() => this.onAddServer()} onSubmitEditing={() => this.onAddServer()}
{...this.props} {...this.props}
/> />
); );
} }
}); });
const styles = StyleSheet.create({ const styles = StyleSheet.create({
inputContainerStyle: { inputContainerStyle: {
marginTop: 8, marginTop: 8,
marginBottom: 12, marginBottom: 12,
backgroundColor: '#292929', backgroundColor: '#292929',
borderBottomWidth: 0 borderBottomWidth: 0
} }
}); });
// Inject the Navigation Hook as a prop to mimic the legacy behavior // Inject the Navigation Hook as a prop to mimic the legacy behavior
const ServerInputWithNavigation = observer((props) => { const ServerInputWithNavigation = observer((props) => {
const stores = useStores(); const stores = useStores();
return <ServerInput {...props} navigation={useNavigation()} {...stores} />; return <ServerInput {...props} navigation={useNavigation()} {...stores} />;
}); });
export default ServerInputWithNavigation; export default ServerInputWithNavigation;

View File

@ -12,65 +12,65 @@ import { useTranslation } from 'react-i18next';
import { getIconName } from '../utils/Icons'; import { getIconName } from '../utils/Icons';
const ServerListItem = ({item, index, activeServer, onDelete, onPress}) => { const ServerListItem = ({item, index, activeServer, onDelete, onPress}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const title = item?.name; const title = item?.name;
const version = item?.info?.Version || t('common.unknown'); const version = item?.info?.Version || t('common.unknown');
const subtitle = `${t('settings.version', { version })}\n${item.urlString}`; const subtitle = `${t('settings.version', { version })}\n${item.urlString}`;
return ( return (
<ListItem <ListItem
title={title} title={title}
titleStyle={styles.title} titleStyle={styles.title}
subtitle={subtitle} subtitle={subtitle}
leftElement={( leftElement={(
index === activeServer ? ( index === activeServer ? (
<Icon <Icon
name={getIconName('checkmark')} name={getIconName('checkmark')}
type='ionicon' type='ionicon'
size={24} size={24}
containerStyle={styles.leftElement} containerStyle={styles.leftElement}
/> />
) : ( ) : (
<View style={styles.leftElement} /> <View style={styles.leftElement} />
) )
)} )}
rightElement={( rightElement={(
<Button <Button
type='clear' type='clear'
icon={{ icon={{
name: getIconName('trash'), name: getIconName('trash'),
type: 'ionicon', type: 'ionicon',
iconStyle: styles.deleteButton iconStyle: styles.deleteButton
}} }}
onPress={() => onDelete(index)} onPress={() => onDelete(index)}
/> />
)} )}
topDivider={index === 0} topDivider={index === 0}
bottomDivider bottomDivider
onPress={() => onPress(index)} onPress={() => onPress(index)}
/> />
); );
}; };
ServerListItem.propTypes = { ServerListItem.propTypes = {
item: PropTypes.object.isRequired, item: PropTypes.object.isRequired,
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
activeServer: PropTypes.number.isRequired, activeServer: PropTypes.number.isRequired,
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onPress: PropTypes.func.isRequired onPress: PropTypes.func.isRequired
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
title: { title: {
marginBottom: 2 marginBottom: 2
}, },
leftElement: { leftElement: {
width: 12 width: 12
}, },
deleteButton: { deleteButton: {
color: Platform.OS === 'ios' ? colors.platform.ios.error : colors.platform.android.error color: Platform.OS === 'ios' ? colors.platform.ios.error : colors.platform.android.error
} }
}); });
export default ServerListItem; export default ServerListItem;

View File

@ -9,23 +9,23 @@ import { ListItem } from 'react-native-elements';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
const SwitchListItem = ({item, index}) => ( const SwitchListItem = ({item, index}) => (
<ListItem <ListItem
title={item.title} title={item.title}
subtitle={item.subtitle} subtitle={item.subtitle}
rightElement={ rightElement={
<Switch <Switch
value={item.value} value={item.value}
onValueChange={item.onValueChange} onValueChange={item.onValueChange}
/> />
} }
topDivider={index === 0} topDivider={index === 0}
bottomDivider bottomDivider
/> />
); );
SwitchListItem.propTypes = { SwitchListItem.propTypes = {
item: PropTypes.object.isRequired, item: PropTypes.object.isRequired,
index: PropTypes.number.isRequired index: PropTypes.number.isRequired
}; };
export default SwitchListItem; export default SwitchListItem;

View File

@ -8,16 +8,16 @@ const textColor = '#fff';
const tintColor = '#00a4dc'; const tintColor = '#00a4dc';
export default { export default {
textColor, textColor,
backgroundColor, backgroundColor,
headerBackgroundColor: '#202020', headerBackgroundColor: '#202020',
tintColor, tintColor,
headerTintColor: textColor, headerTintColor: textColor,
tabText: '#ccc', tabText: '#ccc',
errorBackground: 'red', errorBackground: 'red',
errorText: textColor, errorText: textColor,
warningBackground: '#EAEB5E', warningBackground: '#EAEB5E',
warningText: '#666804', warningText: '#666804',
noticeBackground: tintColor, noticeBackground: tintColor,
noticeText: textColor noticeText: textColor
}; };

View File

@ -6,58 +6,58 @@
import { getIconName } from '../utils/Icons'; import { getIconName } from '../utils/Icons';
export default [ export default [
{ {
key: 'links-website', key: 'links-website',
name: 'links.website', name: 'links.website',
url: 'https://jellyfin.org/', url: 'https://jellyfin.org/',
icon: { icon: {
name: getIconName('globe'), name: getIconName('globe'),
type: 'ionicon' type: 'ionicon'
} }
}, },
{ {
key: 'links-documentation', key: 'links-documentation',
name: 'links.documentation', name: 'links.documentation',
url: 'https://docs.jellyfin.org', url: 'https://docs.jellyfin.org',
icon: { icon: {
name: getIconName('book'), name: getIconName('book'),
type: 'ionicon' type: 'ionicon'
} }
}, },
{ {
key: 'links-source', key: 'links-source',
name: 'links.source', name: 'links.source',
url: 'https://github.com/jellyfin/jellyfin-expo', url: 'https://github.com/jellyfin/jellyfin-expo',
icon: { icon: {
name: 'logo-github', name: 'logo-github',
type: 'ionicon' type: 'ionicon'
} }
}, },
{ {
key: 'links-translate', key: 'links-translate',
name: 'links.translate', name: 'links.translate',
url: 'https://translate.jellyfin.org/projects/jellyfin/jellyfin-expo/', url: 'https://translate.jellyfin.org/projects/jellyfin/jellyfin-expo/',
icon: { icon: {
name: 'translate', name: 'translate',
type: 'material' type: 'material'
} }
}, },
{ {
key: 'links-feature', key: 'links-feature',
name: 'links.feature', name: 'links.feature',
url: 'https://features.jellyfin.org/', url: 'https://features.jellyfin.org/',
icon: { icon: {
name: getIconName('create'), name: getIconName('create'),
type: 'ionicon' type: 'ionicon'
} }
}, },
{ {
key: 'links-issue', key: 'links-issue',
name: 'links.issue', name: 'links.issue',
url: 'https://github.com/jellyfin/jellyfin-expo/issues', url: 'https://github.com/jellyfin/jellyfin-expo/issues',
icon: { icon: {
name: getIconName('bug'), name: getIconName('bug'),
type: 'ionicon' type: 'ionicon'
} }
} }
]; ];

View File

@ -6,6 +6,6 @@
const prefix = 'org.jellyfin.expo'; const prefix = 'org.jellyfin.expo';
export default { export default {
ActiveServer: `${prefix}:ActiveServer`, ActiveServer: `${prefix}:ActiveServer`,
Servers: `${prefix}:Servers` Servers: `${prefix}:Servers`
}; };

View File

@ -8,7 +8,7 @@ import { createContext, useContext } from 'react';
import RootStore from '../stores/RootStore'; import RootStore from '../stores/RootStore';
export const storesContext = createContext({ export const storesContext = createContext({
rootStore: new RootStore() rootStore: new RootStore()
}); });
export const useStores = () => useContext(storesContext); export const useStores = () => useContext(storesContext);

50
i18n.js
View File

@ -19,31 +19,31 @@ import zh_Hans from './langs/zh_Hans.json';
import zh_Hant from './langs/zh_Hant.json'; import zh_Hant from './langs/zh_Hant.json';
const resources = { const resources = {
en: { translation: en }, en: { translation: en },
ar: { translation: ar }, ar: { translation: ar },
cs: { translation: cs }, cs: { translation: cs },
da: { translation: da }, da: { translation: da },
de: { translation: de }, de: { translation: de },
es: { translation: es }, es: { translation: es },
'es-AR': { translation: es_AR }, 'es-AR': { translation: es_AR },
fr: { translation: fr }, fr: { translation: fr },
it: { translation: it }, it: { translation: it },
'nb-NO': { translation: nb_NO }, 'nb-NO': { translation: nb_NO },
sk: { translation: sk }, sk: { translation: sk },
sl: { translation: sl }, sl: { translation: sl },
sv: { translation: sv }, sv: { translation: sv },
'zh-Hans': { translation: zh_Hans }, 'zh-Hans': { translation: zh_Hans },
'zh-Hant': { translation: zh_Hant } 'zh-Hant': { translation: zh_Hant }
}; };
i18next i18next
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
// debug: true, // debug: true,
fallbackLng: 'en', fallbackLng: 'en',
lng: Localization.locale, lng: Localization.locale,
interpolation: { interpolation: {
escapeValue: false escapeValue: false
}, },
resources resources
}); });

View File

@ -10,56 +10,56 @@ import { task } from 'mobx-task';
import JellyfinValidator from '../utils/JellyfinValidator'; import JellyfinValidator from '../utils/JellyfinValidator';
export default class ServerModel { export default class ServerModel {
id id
url url
online = false online = false
info info
constructor(id, url, info) { constructor(id, url, info) {
this.id = id; this.id = id;
this.url = url; this.url = url;
this.info = info; this.info = info;
autorun(() => { autorun(() => {
this.urlString = this.parseUrlString; this.urlString = this.parseUrlString;
}); });
} }
get name() { get name() {
return this.info?.ServerName || this.url?.host; return this.info?.ServerName || this.url?.host;
} }
get parseUrlString() { get parseUrlString() {
try { try {
return JellyfinValidator.getServerUrl(this); return JellyfinValidator.getServerUrl(this);
} catch (ex) { } catch (ex) {
return ''; return '';
} }
} }
fetchInfo = task(async () => { fetchInfo = task(async () => {
return await JellyfinValidator.fetchServerInfo(this) return await JellyfinValidator.fetchServerInfo(this)
.then(action(info => { .then(action(info => {
this.online = true; this.online = true;
this.info = info; this.info = info;
})) }))
.catch((err) => { .catch((err) => {
console.warn(err); console.warn(err);
}); });
}) })
} }
decorate(ServerModel, { decorate(ServerModel, {
id: observable, id: observable,
url: observable, url: observable,
online: [ online: [
ignore, ignore,
observable observable
], ],
info: observable, info: observable,
name: computed, name: computed,
parseUrlString: computed parseUrlString: computed
}); });

View File

@ -21,104 +21,104 @@ import { getIconName } from '../utils/Icons';
// Customize theme for navigator // Customize theme for navigator
const theme = { const theme = {
...DarkTheme, ...DarkTheme,
colors: { colors: {
...DarkTheme.colors, ...DarkTheme.colors,
primary: Colors.tintColor, primary: Colors.tintColor,
background: Colors.backgroundColor, background: Colors.backgroundColor,
card: Colors.headerBackgroundColor, card: Colors.headerBackgroundColor,
text: Colors.textColor, text: Colors.textColor,
border: 'transparent' border: 'transparent'
} }
}; };
const Stack = createStackNavigator(); const Stack = createStackNavigator();
const Tab = createBottomTabNavigator(); const Tab = createBottomTabNavigator();
function TabIcon(routeName, color, size) { function TabIcon(routeName, color, size) {
let iconName = null; let iconName = null;
if (routeName === 'Home') { if (routeName === 'Home') {
iconName = getIconName('tv'); iconName = getIconName('tv');
} else if (routeName === 'Settings') { } else if (routeName === 'Settings') {
iconName = getIconName('cog'); iconName = getIconName('cog');
} }
return ( return (
iconName ? <Ionicons name={iconName} color={color} size={size} /> : null iconName ? <Ionicons name={iconName} color={color} size={size} /> : null
); );
} }
function Main() { function Main() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Tab.Navigator <Tab.Navigator
screenOptions={({ route }) => ({ screenOptions={({ route }) => ({
tabBarIcon: ({ color, size }) => TabIcon(route.name, color, size) tabBarIcon: ({ color, size }) => TabIcon(route.name, color, size)
})} })}
tabBarOptions={{ tabBarOptions={{
inactiveTintColor: Colors.tabText inactiveTintColor: Colors.tabText
}} }}
> >
<Tab.Screen <Tab.Screen
name='Home' name='Home'
component={HomeScreen} component={HomeScreen}
options={{ options={{
title: t('headings.home') title: t('headings.home')
}} }}
/> />
<Tab.Screen <Tab.Screen
name='Settings' name='Settings'
component={SettingsScreen} component={SettingsScreen}
options={{ options={{
title: t('headings.settings') title: t('headings.settings')
}} }}
/> />
</Tab.Navigator> </Tab.Navigator>
); );
} }
const AppNavigator = observer(() => { const AppNavigator = observer(() => {
const { rootStore } = useStores(); const { rootStore } = useStores();
const { t } = useTranslation(); const { t } = useTranslation();
// Ensure the splash screen is hidden when loading is finished // Ensure the splash screen is hidden when loading is finished
SplashScreen.hide(); SplashScreen.hide();
return ( return (
<NavigationContainer theme={theme}> <NavigationContainer theme={theme}>
<Stack.Navigator <Stack.Navigator
initialRouteName={(rootStore.serverStore.servers?.length > 0) ? 'Main' : 'AddServer'} initialRouteName={(rootStore.serverStore.servers?.length > 0) ? 'Main' : 'AddServer'}
headerMode='screen' headerMode='screen'
screenOptions={{ headerShown: false }} screenOptions={{ headerShown: false }}
> >
<Stack.Screen <Stack.Screen
name='Main' name='Main'
component={Main} component={Main}
options={({ route }) => { options={({ route }) => {
const routeName = route.state ? const routeName = route.state ?
// Get the currently active route name in the tab navigator // Get the currently active route name in the tab navigator
route.state.routes[route.state.index].name : route.state.routes[route.state.index].name :
// If state doesn't exist, we need to default to `screen` param if available, or the initial screen // If state doesn't exist, we need to default to `screen` param if available, or the initial screen
// In our case, it's "Main" as that's the first screen inside the navigator // In our case, it's "Main" as that's the first screen inside the navigator
route.params?.screen || 'Main'; route.params?.screen || 'Main';
return ({ return ({
headerShown: routeName === 'Settings', headerShown: routeName === 'Settings',
title: t(`headings.${routeName.toLowerCase()}`) title: t(`headings.${routeName.toLowerCase()}`)
}); });
}} }}
/> />
<Stack.Screen <Stack.Screen
name='AddServer' name='AddServer'
component={AddServerScreen} component={AddServerScreen}
options={{ options={{
headerShown: rootStore.serverStore.servers?.length > 0, headerShown: rootStore.serverStore.servers?.length > 0,
title: t('headings.addServer') title: t('headings.addServer')
}} }}
/> />
</Stack.Navigator> </Stack.Navigator>
</NavigationContainer> </NavigationContainer>
); );
}); });
export default AppNavigator; export default AppNavigator;

View File

@ -11,52 +11,52 @@ import ServerInput from '../components/ServerInput';
import Colors from '../constants/Colors'; import Colors from '../constants/Colors';
const AddServerScreen = () => { const AddServerScreen = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.logoContainer}> <View style={styles.logoContainer}>
<Image <Image
style={styles.logoImage} style={styles.logoImage}
source={require('../assets/images/logowhite.png')} source={require('../assets/images/logowhite.png')}
fadeDuration={0} // we need to adjust Android devices (https://facebook.github.io/react-native/docs/image#fadeduration) fadeDuration prop to `0` as it's default value is `300` fadeDuration={0} // we need to adjust Android devices (https://facebook.github.io/react-native/docs/image#fadeduration) fadeDuration prop to `0` as it's default value is `300`
/> />
</View> </View>
<ServerInput <ServerInput
containerStyle={styles.serverTextContainer} containerStyle={styles.serverTextContainer}
label={t('addServer.address')} label={t('addServer.address')}
placeholder='https://jellyfin.org' placeholder='https://jellyfin.org'
t={t} t={t}
/> />
</View> </View>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
serverTextContainer: { serverTextContainer: {
flex: 1, flex: 1,
alignContent: 'flex-start' alignContent: 'flex-start'
}, },
container: { container: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: Colors.backgroundColor backgroundColor: Colors.backgroundColor
}, },
logoContainer: { logoContainer: {
marginTop: 80, marginTop: 80,
marginBottom: 48, marginBottom: 48,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center' justifyContent: 'center'
}, },
logoImage: { logoImage: {
width: '90%', width: '90%',
height: undefined, height: undefined,
maxWidth: 481, maxWidth: 481,
maxHeight: 151, maxHeight: 151,
// Aspect ration of the logo // Aspect ration of the logo
aspectRatio: 3.18253 aspectRatio: 3.18253
} }
}); });
export default AddServerScreen; export default AddServerScreen;

View File

@ -26,10 +26,10 @@ import { openBrowser } from '../utils/WebBrowser';
const injectedJavaScript = ` const injectedJavaScript = `
window.ExpoAppInfo = { window.ExpoAppInfo = {
appName: '${getAppName()}', appName: '${getAppName()}',
appVersion: '${Constants.nativeAppVersion}', appVersion: '${Constants.nativeAppVersion}',
deviceId: '${Constants.deviceId}', deviceId: '${Constants.deviceId}',
deviceName: '${getSafeDeviceName().replace(/'/g, '\\\'')}' deviceName: '${getSafeDeviceName().replace(/'/g, '\\\'')}'
}; };
${NativeShell} ${NativeShell}
@ -38,254 +38,254 @@ true;
`; `;
const HomeScreen = observer(class HomeScreen extends React.Component { const HomeScreen = observer(class HomeScreen extends React.Component {
state = { state = {
isError: false, isError: false,
isFullscreen: false, isFullscreen: false,
isLoading: true, isLoading: true,
isRefreshing: false isRefreshing: false
}; };
static propTypes = { static propTypes = {
navigation: PropTypes.object.isRequired, navigation: PropTypes.object.isRequired,
route: PropTypes.object.isRequired, route: PropTypes.object.isRequired,
rootStore: PropTypes.object.isRequired, rootStore: PropTypes.object.isRequired,
t: PropTypes.func.isRequired t: PropTypes.func.isRequired
} }
getErrorView() { getErrorView() {
const { t } = this.props; const { t } = this.props;
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.error}>{t('home.offline')}</Text> <Text style={styles.error}>{t('home.offline')}</Text>
<Button <Button
buttonStyle={{ buttonStyle={{
marginLeft: 15, marginLeft: 15,
marginRight: 15 marginRight: 15
}} }}
icon={{ icon={{
name: getIconName('refresh'), name: getIconName('refresh'),
type: 'ionicon' type: 'ionicon'
}} }}
title={t('home.retry')} title={t('home.retry')}
onPress={() => this.onRefresh()} onPress={() => this.onRefresh()}
/> />
</View> </View>
); );
} }
onGoHome() { onGoHome() {
this.webview.injectJavaScript('window.Emby && window.Emby.Page && typeof window.Emby.Page.goHome === "function" && window.Emby.Page.goHome();'); this.webview.injectJavaScript('window.Emby && window.Emby.Page && typeof window.Emby.Page.goHome === "function" && window.Emby.Page.goHome();');
} }
async onMessage({ nativeEvent: state }) { async onMessage({ nativeEvent: state }) {
try { try {
const { event, data } = JSON.parse(state.data); const { event, data } = JSON.parse(state.data);
switch (event) { switch (event) {
case 'enableFullscreen': case 'enableFullscreen':
this.setState({ isFullscreen: true }); this.setState({ isFullscreen: true });
break; break;
case 'disableFullscreen': case 'disableFullscreen':
this.setState({ isFullscreen: false }); this.setState({ isFullscreen: false });
break; break;
case 'openUrl': case 'openUrl':
console.log('Opening browser for external url', data.url); console.log('Opening browser for external url', data.url);
openBrowser(data.url); openBrowser(data.url);
break; break;
case 'updateMediaSession': case 'updateMediaSession':
// Keep the screen awake when music is playing // Keep the screen awake when music is playing
if (this.props.rootStore.settingStore.isScreenLockEnabled) { if (this.props.rootStore.settingStore.isScreenLockEnabled) {
activateKeepAwake(); activateKeepAwake();
} }
break; break;
case 'hideMediaSession': case 'hideMediaSession':
// When music session stops disable keep awake // When music session stops disable keep awake
if (this.props.rootStore.settingStore.isScreenLockEnabled) { if (this.props.rootStore.settingStore.isScreenLockEnabled) {
deactivateKeepAwake(); deactivateKeepAwake();
} }
break; break;
case 'console.debug': case 'console.debug':
console.debug('[Browser Console]', data); console.debug('[Browser Console]', data);
break; break;
case 'console.error': case 'console.error':
console.error('[Browser Console]', data); console.error('[Browser Console]', data);
break; break;
case 'console.info': case 'console.info':
console.info('[Browser Console]', data); console.info('[Browser Console]', data);
break; break;
case 'console.log': case 'console.log':
console.log('[Browser Console]', data); console.log('[Browser Console]', data);
break; break;
case 'console.warn': case 'console.warn':
console.warn('[Browser Console]', data); console.warn('[Browser Console]', data);
break; break;
default: default:
console.debug('[HomeScreen.onMessage]', event, data); console.debug('[HomeScreen.onMessage]', event, data);
} }
} catch (ex) { } catch (ex) {
console.warn('Exception handling message', state.data); console.warn('Exception handling message', state.data);
} }
} }
onRefresh() { onRefresh() {
// Disable pull to refresh when in fullscreen // Disable pull to refresh when in fullscreen
if (this.state.isFullscreen) return; if (this.state.isFullscreen) return;
this.setState({ this.setState({
isLoading: true, isLoading: true,
isRefreshing: true isRefreshing: true
}); });
this.webview.reload(); this.webview.reload();
this.setState({ isRefreshing: false }); this.setState({ isRefreshing: false });
} }
async updateScreenOrientation() { async updateScreenOrientation() {
if (this.props.rootStore.settingStore.isRotationEnabled) { if (this.props.rootStore.settingStore.isRotationEnabled) {
if (this.state.isFullscreen) { if (this.state.isFullscreen) {
// Lock to landscape orientation // Lock to landscape orientation
// For some reason video apps on iPhone use LANDSCAPE_RIGHT ¯\_(ツ)_/¯ // For some reason video apps on iPhone use LANDSCAPE_RIGHT ¯\_(ツ)_/¯
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT); await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT);
// Allow either landscape orientation after forcing initial rotation // Allow either landscape orientation after forcing initial rotation
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
} else { } else {
// Restore portrait orientation lock // Restore portrait orientation lock
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
} }
} }
} }
componentDidMount() { componentDidMount() {
// Override the default tab press behavior so a second press sends the webview home // Override the default tab press behavior so a second press sends the webview home
this.props.navigation.addListener('tabPress', e => { this.props.navigation.addListener('tabPress', e => {
if (this.props.navigation.isFocused()) { if (this.props.navigation.isFocused()) {
// Prevent default behavior // Prevent default behavior
e.preventDefault(); e.preventDefault();
this.onGoHome(); this.onGoHome();
} }
}); });
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if (prevState.isFullscreen !== this.state.isFullscreen) { if (prevState.isFullscreen !== this.state.isFullscreen) {
// Update the screen orientation // Update the screen orientation
this.updateScreenOrientation(); this.updateScreenOrientation();
// Show/hide the bottom tab bar // Show/hide the bottom tab bar
this.props.navigation.setOptions({ this.props.navigation.setOptions({
tabBarVisible: !this.state.isFullscreen tabBarVisible: !this.state.isFullscreen
}); });
// Show/hide the status bar // Show/hide the status bar
setStatusBarHidden(this.state.isFullscreen); setStatusBarHidden(this.state.isFullscreen);
} }
} }
render() { render() {
// When not in fullscreen, the top adjustment is handled by the spacer View for iOS // When not in fullscreen, the top adjustment is handled by the spacer View for iOS
const safeAreaEdges = ['right', 'bottom', 'left']; const safeAreaEdges = ['right', 'bottom', 'left'];
if (Platform.OS !== 'ios' || this.state.isFullscreen) { if (Platform.OS !== 'ios' || this.state.isFullscreen) {
safeAreaEdges.push('top'); safeAreaEdges.push('top');
} }
// Hide webview until loaded // Hide webview until loaded
const webviewStyle = (this.state.isError || this.state.isLoading) ? styles.loading : styles.container; const webviewStyle = (this.state.isError || this.state.isLoading) ? styles.loading : styles.container;
if (!this.props.rootStore.serverStore.servers || this.props.rootStore.serverStore.servers.length === 0) { if (!this.props.rootStore.serverStore.servers || this.props.rootStore.serverStore.servers.length === 0) {
return null; return null;
} }
const server = this.props.rootStore.serverStore.servers[this.props.rootStore.settingStore.activeServer]; const server = this.props.rootStore.serverStore.servers[this.props.rootStore.settingStore.activeServer];
return ( return (
<SafeAreaView style={styles.container} edges={safeAreaEdges} > <SafeAreaView style={styles.container} edges={safeAreaEdges} >
{Platform.OS === 'ios' && !this.state.isFullscreen && ( {Platform.OS === 'ios' && !this.state.isFullscreen && (
<View style={styles.statusBarSpacer} /> <View style={styles.statusBarSpacer} />
)} )}
<ScrollView <ScrollView
style={styles.container} style={styles.container}
contentContainerStyle={{flexGrow: 1}} contentContainerStyle={{flexGrow: 1}}
refreshControl={ refreshControl={
Platform.OS === 'ios' ? ( Platform.OS === 'ios' ? (
<RefreshControl <RefreshControl
refreshing={this.state.isRefreshing} refreshing={this.state.isRefreshing}
onRefresh={() => this.onRefresh()} onRefresh={() => this.onRefresh()}
tintColor={Colors.tabText} tintColor={Colors.tabText}
/> />
) : null ) : null
} }
> >
{server && server.urlString && ( {server && server.urlString && (
<WebView <WebView
ref={ref => (this.webview = ref)} ref={ref => (this.webview = ref)}
source={{ uri: server.urlString }} source={{ uri: server.urlString }}
style={webviewStyle} style={webviewStyle}
// Inject javascript for NativeShell // Inject javascript for NativeShell
injectedJavaScriptBeforeContentLoaded={injectedJavaScript} injectedJavaScriptBeforeContentLoaded={injectedJavaScript}
// Handle messages from NativeShell // Handle messages from NativeShell
onMessage={this.onMessage.bind(this)} onMessage={this.onMessage.bind(this)}
// Make scrolling feel faster // Make scrolling feel faster
decelerationRate='normal' decelerationRate='normal'
// Error screen is displayed if loading fails // Error screen is displayed if loading fails
renderError={() => this.getErrorView()} renderError={() => this.getErrorView()}
// Loading screen is displayed when refreshing // Loading screen is displayed when refreshing
renderLoading={() => <View style={styles.container} />} renderLoading={() => <View style={styles.container} />}
// Update state on loading error // Update state on loading error
onError={({ nativeEvent: state }) => { onError={({ nativeEvent: state }) => {
console.warn('Error', state); console.warn('Error', state);
this.setState({ isError: true }); this.setState({ isError: true });
}} }}
// Update state when loading is complete // Update state when loading is complete
onLoad={() => { onLoad={() => {
this.setState({ this.setState({
isError: false, isError: false,
isLoading: false isLoading: false
}); });
}} }}
// Media playback options to fix video player // Media playback options to fix video player
allowsInlineMediaPlayback={true} allowsInlineMediaPlayback={true}
mediaPlaybackRequiresUserAction={false} mediaPlaybackRequiresUserAction={false}
// Use WKWebView on iOS // Use WKWebView on iOS
useWebKit={true} useWebKit={true}
/> />
)} )}
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );
} }
}); });
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: Colors.backgroundColor backgroundColor: Colors.backgroundColor
}, },
loading: { loading: {
flex: 1, flex: 1,
backgroundColor: Colors.backgroundColor, backgroundColor: Colors.backgroundColor,
opacity: 0 opacity: 0
}, },
statusBarSpacer: { statusBarSpacer: {
backgroundColor: Colors.headerBackgroundColor, backgroundColor: Colors.headerBackgroundColor,
height: Constants.statusBarHeight height: Constants.statusBarHeight
}, },
error: { error: {
fontSize: 17, fontSize: 17,
paddingBottom: 17, paddingBottom: 17,
textAlign: 'center' textAlign: 'center'
} }
}); });
// Inject the Navigation Hook as a prop to mimic the legacy behavior // Inject the Navigation Hook as a prop to mimic the legacy behavior
const HomeScreenWithNavigation = observer((props) => { const HomeScreenWithNavigation = observer((props) => {
const stores = useStores(); const stores = useStores();
const translation = useTranslation(); const translation = useTranslation();
return ( return (
<HomeScreen <HomeScreen
{...props} {...props}
navigation={useNavigation()} navigation={useNavigation()}
route={useRoute()} route={useRoute()}
{...stores} {...stores}
{...translation} {...translation}
/> />
); );
}); });
export default HomeScreenWithNavigation; export default HomeScreenWithNavigation;

View File

@ -10,30 +10,30 @@ import Colors from '../constants/Colors';
import { SplashScreen } from 'expo'; import { SplashScreen } from 'expo';
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: Colors.backgroundColor backgroundColor: Colors.backgroundColor
}, },
splash: { splash: {
flex: 1, flex: 1,
resizeMode: 'contain', resizeMode: 'contain',
width: undefined, width: undefined,
height: undefined height: undefined
} }
}); });
function LoadingScreen() { function LoadingScreen() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Image <Image
style={styles.splash} style={styles.splash}
source={require('../assets/images/splash.png')} source={require('../assets/images/splash.png')}
onLoad={() => SplashScreen.hide()} onLoad={() => SplashScreen.hide()}
fadeDuration={0} // we need to adjust Android devices (https://facebook.github.io/react-native/docs/image#fadeduration) fadeDuration prop to `0` as it's default value is `300` fadeDuration={0} // we need to adjust Android devices (https://facebook.github.io/react-native/docs/image#fadeduration) fadeDuration prop to `0` as it's default value is `300`
/> />
<ActivityIndicator /> <ActivityIndicator />
</View> </View>
); );
} }
export default LoadingScreen; export default LoadingScreen;

View File

@ -21,181 +21,181 @@ import Links from '../constants/Links';
import { useStores } from '../hooks/useStores'; import { useStores } from '../hooks/useStores';
const SettingsScreen = observer(() => { const SettingsScreen = observer(() => {
const { rootStore } = useStores(); const { rootStore } = useStores();
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
// Fetch server info // Fetch server info
rootStore.serverStore.fetchInfo(); rootStore.serverStore.fetchInfo();
}, []); }, []);
const onAddServer = () => { const onAddServer = () => {
navigation.navigate('AddServer'); navigation.navigate('AddServer');
}; };
const onDeleteServer = index => { const onDeleteServer = index => {
Alert.alert( Alert.alert(
t('alerts.deleteServer.title'), t('alerts.deleteServer.title'),
t('alerts.deleteServer.description', { serverName: rootStore.serverStore.servers[index]?.name }), t('alerts.deleteServer.description', { serverName: rootStore.serverStore.servers[index]?.name }),
[ [
{ text: t('common.cancel') }, { text: t('common.cancel') },
{ {
text: t('alerts.deleteServer.confirm'), text: t('alerts.deleteServer.confirm'),
onPress: () => { onPress: () => {
// Remove server and update active server // Remove server and update active server
rootStore.serverStore.removeServer(index); rootStore.serverStore.removeServer(index);
rootStore.settingStore.activeServer = 0; rootStore.settingStore.activeServer = 0;
if (rootStore.serverStore.servers.length > 0) { if (rootStore.serverStore.servers.length > 0) {
// More servers exist, navigate home // More servers exist, navigate home
navigation.navigate('Home'); navigation.navigate('Home');
} else { } else {
// No servers are present, navigate to add server screen // No servers are present, navigate to add server screen
navigation.replace('AddServer'); navigation.replace('AddServer');
} }
}, },
style: 'destructive' style: 'destructive'
} }
] ]
); );
}; };
const onSelectServer = index => { const onSelectServer = index => {
rootStore.settingStore.activeServer = index; rootStore.settingStore.activeServer = index;
navigation.navigate('Home'); navigation.navigate('Home');
}; };
const onResetApplication = () => { const onResetApplication = () => {
Alert.alert( Alert.alert(
t('alerts.resetApplication.title'), t('alerts.resetApplication.title'),
t('alerts.resetApplication.description'), t('alerts.resetApplication.description'),
[ [
{ text: t('common.cancel') }, { text: t('common.cancel') },
{ {
text: t('alerts.resetApplication.confirm'), text: t('alerts.resetApplication.confirm'),
onPress: () => { onPress: () => {
// Reset data in stores // Reset data in stores
rootStore.reset(); rootStore.reset();
AsyncStorage.clear(); AsyncStorage.clear();
// Navigate to the loading screen // Navigate to the loading screen
navigation.replace('AddServer'); navigation.replace('AddServer');
}, },
style: 'destructive' style: 'destructive'
} }
] ]
); );
}; };
const AugmentedServerListItem = (props) => ( const AugmentedServerListItem = (props) => (
<ServerListItem <ServerListItem
{...props} {...props}
activeServer={rootStore.settingStore.activeServer} activeServer={rootStore.settingStore.activeServer}
onDelete={onDeleteServer} onDelete={onDeleteServer}
onPress={onSelectServer} onPress={onSelectServer}
/> />
); );
const getSections = () => { const getSections = () => {
return [ return [
{ {
title: t('headings.servers'), title: t('headings.servers'),
data: rootStore.serverStore.servers.slice(), data: rootStore.serverStore.servers.slice(),
keyExtractor: (item, index) => `server-${index}`, keyExtractor: (item, index) => `server-${index}`,
renderItem: AugmentedServerListItem renderItem: AugmentedServerListItem
}, },
{ {
title: t('headings.addServer'), title: t('headings.addServer'),
hideHeader: true, hideHeader: true,
data: [{ data: [{
key: 'add-server-button', key: 'add-server-button',
title: t('headings.addServer'), title: t('headings.addServer'),
onPress: onAddServer onPress: onAddServer
}], }],
renderItem: ButtonListItem renderItem: ButtonListItem
}, },
{ {
title: t('headings.settings'), title: t('headings.settings'),
data: [ data: [
{ {
key: 'keep-awake-switch', key: 'keep-awake-switch',
title: t('settings.keepAwake'), title: t('settings.keepAwake'),
value: rootStore.settingStore.isScreenLockEnabled, value: rootStore.settingStore.isScreenLockEnabled,
onValueChange: value => rootStore.settingStore.isScreenLockEnabled = value onValueChange: value => rootStore.settingStore.isScreenLockEnabled = value
}, },
{ {
key: 'rotation-lock-switch', key: 'rotation-lock-switch',
title: t('settings.rotationLock'), title: t('settings.rotationLock'),
value: rootStore.settingStore.isRotationEnabled, value: rootStore.settingStore.isRotationEnabled,
onValueChange: value => rootStore.settingStore.isRotationEnabled = value onValueChange: value => rootStore.settingStore.isRotationEnabled = value
} }
], ],
renderItem: SwitchListItem renderItem: SwitchListItem
}, },
{ {
title: t('headings.links'), title: t('headings.links'),
data: Links.map(link => ({ data: Links.map(link => ({
...link, ...link,
name: t(link.name) name: t(link.name)
})), })),
renderItem: BrowserListItem renderItem: BrowserListItem
}, },
{ {
title: t('alerts.resetApplication.title'), title: t('alerts.resetApplication.title'),
hideHeader: true, hideHeader: true,
data: [{ data: [{
key: 'reset-app-button', key: 'reset-app-button',
title: t('alerts.resetApplication.title'), title: t('alerts.resetApplication.title'),
titleStyle: { titleStyle: {
color: Platform.OS === 'ios' ? colors.platform.ios.error : colors.platform.android.error color: Platform.OS === 'ios' ? colors.platform.ios.error : colors.platform.android.error
}, },
onPress: onResetApplication onPress: onResetApplication
}], }],
renderItem: ButtonListItem renderItem: ButtonListItem
} }
]; ];
}; };
return ( return (
<SafeAreaView style={styles.container} edges={['right', 'bottom', 'left']} > <SafeAreaView style={styles.container} edges={['right', 'bottom', 'left']} >
<SectionList <SectionList
sections={getSections()} sections={getSections()}
extraData={{ extraData={{
activeServer: rootStore.settingStore.activeServer, activeServer: rootStore.settingStore.activeServer,
isFetching: rootStore.serverStore.fetchInfo.pending isFetching: rootStore.serverStore.fetchInfo.pending
}} }}
renderItem={({ item }) => <Text>{JSON.stringify(item)}</Text>} renderItem={({ item }) => <Text>{JSON.stringify(item)}</Text>}
renderSectionHeader={({ section: { title, hideHeader } }) => ( renderSectionHeader={({ section: { title, hideHeader } }) => (
hideHeader ? <View style={styles.emptyHeader} /> : <Text style={styles.header}>{title}</Text> hideHeader ? <View style={styles.emptyHeader} /> : <Text style={styles.header}>{title}</Text>
)} )}
renderSectionFooter={() => <View style={styles.footer} />} renderSectionFooter={() => <View style={styles.footer} />}
ListFooterComponent={AppInfoFooter} ListFooterComponent={AppInfoFooter}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
/> />
</SafeAreaView> </SafeAreaView>
); );
}); });
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: Colors.backgroundColor backgroundColor: Colors.backgroundColor
}, },
header: { header: {
backgroundColor: Colors.backgroundColor, backgroundColor: Colors.backgroundColor,
color: colors.grey4, color: colors.grey4,
fontSize: 17, fontSize: 17,
fontWeight: '600', fontWeight: '600',
paddingVertical: 8, paddingVertical: 8,
paddingHorizontal: 15, paddingHorizontal: 15,
marginBottom: 1 marginBottom: 1
}, },
emptyHeader: { emptyHeader: {
marginTop: 15 marginTop: 15
}, },
footer: { footer: {
marginBottom: 15 marginBottom: 15
} }
}); });
export default SettingsScreen; export default SettingsScreen;

View File

@ -6,21 +6,21 @@
import { decorate } from 'mobx'; import { decorate } from 'mobx';
import { ignore } from 'mobx-sync'; import { ignore } from 'mobx-sync';
import ServerStore from "./ServerStore"; import ServerStore from './ServerStore';
import SettingStore from "./SettingStore"; import SettingStore from './SettingStore';
export default class RootStore { export default class RootStore {
storeLoaded = false storeLoaded = false
serverStore = new ServerStore() serverStore = new ServerStore()
settingStore = new SettingStore() settingStore = new SettingStore()
reset() { reset() {
this.serverStore.reset(); this.serverStore.reset();
this.settingStore.reset(); this.settingStore.reset();
} }
} }
decorate(RootStore, { decorate(RootStore, {
storeLoaded: ignore storeLoaded: ignore
}); });

View File

@ -10,33 +10,33 @@ import { task } from 'mobx-task';
import ServerModel from '../models/ServerModel'; import ServerModel from '../models/ServerModel';
export default class ServerStore { export default class ServerStore {
servers = [] servers = []
addServer(server) { addServer(server) {
this.servers.push(new ServerModel(this.servers.length, server.url)); this.servers.push(new ServerModel(this.servers.length, server.url));
} }
removeServer(index) { removeServer(index) {
this.servers.splice(index, 1); this.servers.splice(index, 1);
} }
reset() { reset() {
this.servers = []; this.servers = [];
} }
fetchInfo = task(async () => { fetchInfo = task(async () => {
await Promise.all( await Promise.all(
this.servers.map(server => server.fetchInfo()) this.servers.map(server => server.fetchInfo())
); );
}) })
} }
decorate(ServerStore, { decorate(ServerStore, {
servers: [ servers: [
format(data => data.map(value => new ServerModel(value.id, value.url, value.info))), format(data => data.map(value => new ServerModel(value.id, value.url, value.info))),
observable observable
], ],
addServer: action, addServer: action,
removeServer: action, removeServer: action,
reset: action reset: action
}); });

View File

@ -9,31 +9,31 @@ import { action, decorate, observable } from 'mobx';
* Data store for application settings * Data store for application settings
*/ */
export default class SettingStore { export default class SettingStore {
/** /**
* The id of the currently selected server * The id of the currently selected server
*/ */
activeServer = 0 activeServer = 0
/** /**
* Is device rotation enabled * Is device rotation enabled
*/ */
isRotationEnabled isRotationEnabled
/** /**
* Is screen lock active when media is playing * Is screen lock active when media is playing
*/ */
isScreenLockEnabled = true isScreenLockEnabled = true
reset() { reset() {
this.activeServer = 0; this.activeServer = 0;
this.isRotationEnabled = null; this.isRotationEnabled = null;
this.isScreenLockEnabled = true; this.isScreenLockEnabled = true;
} }
} }
decorate(SettingStore, { decorate(SettingStore, {
activeServer: observable, activeServer: observable,
isRotationEnabled: observable, isRotationEnabled: observable,
isScreenLockEnabled: observable, isScreenLockEnabled: observable,
reset: action reset: action
}); });

View File

@ -10,50 +10,50 @@ import { AsyncStorage } from 'react-native';
* @deprecated * @deprecated
*/ */
export default class CachingStorage { export default class CachingStorage {
static instance = null; static instance = null;
cache = new Map(); cache = new Map();
static getInstance() { static getInstance() {
if (this.instance === null) { if (this.instance === null) {
console.debug('CachingStorage: Initializing new instance'); console.debug('CachingStorage: Initializing new instance');
this.instance = new CachingStorage(); this.instance = new CachingStorage();
} else { } else {
console.debug('CachingStorage: Using existing instance'); console.debug('CachingStorage: Using existing instance');
} }
return this.instance; return this.instance;
} }
async getItem(key) { async getItem(key) {
// Throw an error if no key is provided // Throw an error if no key is provided
if (!key) { if (!key) {
throw new Error('No key specified for `getItem()`'); throw new Error('No key specified for `getItem()`');
} }
// Return cached value if present // Return cached value if present
if (this.cache.has(key)) { if (this.cache.has(key)) {
console.debug(`CachingStorage: Returning value from cache for ${key}`); console.debug(`CachingStorage: Returning value from cache for ${key}`);
return this.cache.get(key); return this.cache.get(key);
} }
// Get the item from device storage // Get the item from device storage
console.debug(`CachingStorage: Loading value from device storage for ${key}`); console.debug(`CachingStorage: Loading value from device storage for ${key}`);
let item = await AsyncStorage.getItem(key); let item = await AsyncStorage.getItem(key);
if (item !== null) { if (item !== null) {
item = JSON.parse(item); item = JSON.parse(item);
this.cache.set(key, item); this.cache.set(key, item);
} }
return item; return item;
} }
async setItem(key, item = '') { async setItem(key, item = '') {
// Throw an error if no key is provided // Throw an error if no key is provided
if (!key) { if (!key) {
throw new Error('No key specified for `setItem()`'); throw new Error('No key specified for `setItem()`');
} }
console.debug(`CachingStorage: Saving value to device storage for ${key}`); console.debug(`CachingStorage: Saving value to device storage for ${key}`);
await AsyncStorage.setItem(key, JSON.stringify(item)); await AsyncStorage.setItem(key, JSON.stringify(item));
this.cache.set(key, item); this.cache.set(key, item);
} }
} }

View File

@ -7,20 +7,20 @@ import Constants from 'expo-constants';
import * as Device from 'expo-device'; import * as Device from 'expo-device';
export function getAppName() { export function getAppName() {
return `Jellyfin Mobile (${Device.osName})`; return `Jellyfin Mobile (${Device.osName})`;
} }
export function getSafeDeviceName() { export function getSafeDeviceName() {
let safeName = Constants.deviceName let safeName = Constants.deviceName
// Replace non-ascii apostrophe with single quote (default on iOS) // Replace non-ascii apostrophe with single quote (default on iOS)
.replace(//g, '\'') .replace(//g, '\'')
// Remove all other non-ascii characters // Remove all other non-ascii characters
.replace(/[^\x20-\x7E]/g, '') .replace(/[^\x20-\x7E]/g, '')
// Trim whitespace // Trim whitespace
.trim(); .trim();
if (safeName) { if (safeName) {
return safeName; return safeName;
} }
return Device.modelName || 'Jellyfin Mobile Device'; return Device.modelName || 'Jellyfin Mobile Device';
} }

View File

@ -6,8 +6,8 @@
import { Platform } from 'react-native'; import { Platform } from 'react-native';
export const getIconName = (name = '') => { export const getIconName = (name = '') => {
if (name) { if (name) {
return Platform.OS === 'ios' ? `ios-${name}` : `md-${name}`; return Platform.OS === 'ios' ? `ios-${name}` : `md-${name}`;
} }
return name; return name;
}; };

View File

@ -7,118 +7,118 @@
import Url from 'url'; import Url from 'url';
export default class JellyfinValidator { export default class JellyfinValidator {
static TIMEOUT_DURATION = 5000 // timeout request after 5s static TIMEOUT_DURATION = 5000 // timeout request after 5s
static parseUrl(host = '', port = '') { static parseUrl(host = '', port = '') {
if (!host) { if (!host) {
throw new Error('host cannot be blank'); throw new Error('host cannot be blank');
} }
// Default the protocol to http if not present // Default the protocol to http if not present
// Setting the protocol on the parsed url does not update the href // Setting the protocol on the parsed url does not update the href
if (!host.startsWith('http://') && !host.startsWith('https://')) { if (!host.startsWith('http://') && !host.startsWith('https://')) {
host = `http://${host}`; host = `http://${host}`;
} }
// Parse the host as a url // Parse the host as a url
const url = Url.parse(host); const url = Url.parse(host);
if (!url.hostname) { if (!url.hostname) {
throw new Error(`Could not parse hostname for ${host}`); throw new Error(`Could not parse hostname for ${host}`);
} }
// Override the port if provided // Override the port if provided
if (port) { if (port) {
url.port = port; url.port = port;
} }
return url; return url;
} }
static async fetchServerInfo(server = {}) { static async fetchServerInfo(server = {}) {
const serverUrl = server.urlString || this.getServerUrl(server); const serverUrl = server.urlString || this.getServerUrl(server);
const infoUrl = `${serverUrl}system/info/public`; const infoUrl = `${serverUrl}system/info/public`;
console.log('info url', infoUrl); console.log('info url', infoUrl);
// Try to fetch the server's public info // Try to fetch the server's public info
const controller = new AbortController(); const controller = new AbortController();
const { signal } = controller; const { signal } = controller;
const request = fetch(infoUrl, { signal }); const request = fetch(infoUrl, { signal });
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
console.log('request timed out, aborting'); console.log('request timed out, aborting');
controller.abort(); controller.abort();
}, this.TIMEOUT_DURATION); }, this.TIMEOUT_DURATION);
const responseJson = await request.then(response => { const responseJson = await request.then(response => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
throw new Error(`Error response status [${response.status}] received from ${infoUrl}`); throw new Error(`Error response status [${response.status}] received from ${infoUrl}`);
} }
return response.json(); return response.json();
}); });
console.log('response', responseJson); console.log('response', responseJson);
return responseJson; return responseJson;
} }
static getServerUrl(server = {}) { static getServerUrl(server = {}) {
if (!server || !server.url || !server.url.href) { if (!server || !server.url || !server.url.href) {
throw new Error('Cannot get server url for invalid server', server); throw new Error('Cannot get server url for invalid server', server);
} }
// Strip the query string or hash if present // Strip the query string or hash if present
let serverUrl = server.url.href; let serverUrl = server.url.href;
if (server.url.search || server.url.hash) { if (server.url.search || server.url.hash) {
const endUrl = server.url.search || server.url.hash; const endUrl = server.url.search || server.url.hash;
serverUrl = serverUrl.substring(0, serverUrl.indexOf(endUrl)); serverUrl = serverUrl.substring(0, serverUrl.indexOf(endUrl));
} }
// Ensure the url ends with / // Ensure the url ends with /
if (!serverUrl.endsWith('/')) { if (!serverUrl.endsWith('/')) {
serverUrl += '/'; serverUrl += '/';
} }
console.log('getServerUrl:', serverUrl); console.log('getServerUrl:', serverUrl);
return serverUrl; return serverUrl;
} }
static async validate(server = {}) { static async validate(server = {}) {
try { try {
// Does the server have a valid url? // Does the server have a valid url?
this.getServerUrl(server); this.getServerUrl(server);
} catch (err) { } catch (err) {
return { return {
isValid: false, isValid: false,
message: 'invalid' message: 'invalid'
}; };
} }
try { try {
const responseJson = await this.fetchServerInfo(server); const responseJson = await this.fetchServerInfo(server);
// Versions prior to 10.3.x do not include ProductName so return true if response includes Version < 10.3.x // Versions prior to 10.3.x do not include ProductName so return true if response includes Version < 10.3.x
if (responseJson.Version) { if (responseJson.Version) {
const versionNumber = responseJson.Version.split('.').map(num => Number.parseInt(num, 10)); const versionNumber = responseJson.Version.split('.').map(num => Number.parseInt(num, 10));
if (versionNumber.length === 3 && versionNumber[0] === 10 && versionNumber[1] < 3) { if (versionNumber.length === 3 && versionNumber[0] === 10 && versionNumber[1] < 3) {
console.log('Is valid old version'); console.log('Is valid old version');
return { isValid: true }; return { isValid: true };
} }
} }
const isValid = responseJson.ProductName === 'Jellyfin Server'; const isValid = responseJson.ProductName === 'Jellyfin Server';
const answer = { const answer = {
isValid isValid
}; };
if (!isValid) { if (!isValid) {
answer.message = 'invalidProduct'; answer.message = 'invalidProduct';
} }
return answer; return answer;
} catch (err) { } catch (err) {
return { return {
isValid: false, isValid: false,
message: 'noConnection' message: 'noConnection'
}; };
} }
} }
} }

View File

@ -9,55 +9,55 @@ import { colors } from 'react-native-elements';
import Colors from '../constants/Colors'; import Colors from '../constants/Colors';
export default { export default {
colors: { colors: {
primary: Colors.tintColor primary: Colors.tintColor
}, },
Badge: { Badge: {
badgeStyle: { badgeStyle: {
borderWidth: 0 borderWidth: 0
} }
}, },
Icon: { Icon: {
iconStyle: { iconStyle: {
color: Colors.textColor color: Colors.textColor
} }
}, },
Input: { Input: {
inputStyle: { inputStyle: {
color: Colors.textColor color: Colors.textColor
}, },
errorStyle: { errorStyle: {
color: Platform.OS === 'ios' ? colors.platform.ios.error : colors.platform.android.error, color: Platform.OS === 'ios' ? colors.platform.ios.error : colors.platform.android.error,
fontSize: 16 fontSize: 16
}, },
leftIconContainerStyle: { leftIconContainerStyle: {
marginRight: 8 marginRight: 8
}, },
rightIconContainerStyle: { rightIconContainerStyle: {
marginRight: 15 marginRight: 15
} }
}, },
ListItem: { ListItem: {
containerStyle: { containerStyle: {
backgroundColor: Colors.headerBackgroundColor backgroundColor: Colors.headerBackgroundColor
}, },
subtitleStyle: { subtitleStyle: {
color: colors.grey4, color: colors.grey4,
lineHeight: 21 lineHeight: 21
}, },
rightSubtitleStyle: { rightSubtitleStyle: {
color: colors.grey4 color: colors.grey4
} }
}, },
Overlay: { Overlay: {
windowBackgroundColor: 'rgba(0, 0, 0, .85)', windowBackgroundColor: 'rgba(0, 0, 0, .85)',
overlayStyle: { overlayStyle: {
backgroundColor: Colors.backgroundColor backgroundColor: Colors.backgroundColor
} }
}, },
Text: { Text: {
style: { style: {
color: Colors.textColor color: Colors.textColor
} }
} }
}; };

View File

@ -3,30 +3,30 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import * as WebBrowser from "expo-web-browser"; import * as WebBrowser from 'expo-web-browser';
import Colors from '../constants/Colors'; import Colors from '../constants/Colors';
export async function openBrowser(url, options) { export async function openBrowser(url, options) {
const finalOptions = Object.assign({ const finalOptions = Object.assign({
toolbarColor: Colors.backgroundColor, toolbarColor: Colors.backgroundColor,
controlsColor: Colors.tintColor controlsColor: Colors.tintColor
}, options); }, options);
try { try {
await WebBrowser.openBrowserAsync(url, finalOptions); await WebBrowser.openBrowserAsync(url, finalOptions);
} catch (err) { } catch (err) {
// Workaround issue where swiping browser closed does not dismiss it. // Workaround issue where swiping browser closed does not dismiss it.
// https://github.com/expo/expo/issues/6918 // https://github.com/expo/expo/issues/6918
if (err.message === 'Another WebBrowser is already being presented.') { if (err.message === 'Another WebBrowser is already being presented.') {
try { try {
await WebBrowser.dismissBrowser(); await WebBrowser.dismissBrowser();
return WebBrowser.openBrowserAsync(url, finalOptions); return WebBrowser.openBrowserAsync(url, finalOptions);
} catch (retryErr) { } catch (retryErr) {
console.warn('Could not dismiss and reopen browser', retryErr); console.warn('Could not dismiss and reopen browser', retryErr);
} }
} else { } else {
console.warn('Could not open browser', err); console.warn('Could not open browser', err);
} }
} }
} }