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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import { createContext, useContext } from 'react';
import RootStore from '../stores/RootStore';
export const storesContext = createContext({
rootStore: new RootStore()
rootStore: new RootStore()
});
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';
const resources = {
en: { translation: en },
ar: { translation: ar },
cs: { translation: cs },
da: { translation: da },
de: { translation: de },
es: { translation: es },
'es-AR': { translation: es_AR },
fr: { translation: fr },
it: { translation: it },
'nb-NO': { translation: nb_NO },
sk: { translation: sk },
sl: { translation: sl },
sv: { translation: sv },
'zh-Hans': { translation: zh_Hans },
'zh-Hant': { translation: zh_Hant }
en: { translation: en },
ar: { translation: ar },
cs: { translation: cs },
da: { translation: da },
de: { translation: de },
es: { translation: es },
'es-AR': { translation: es_AR },
fr: { translation: fr },
it: { translation: it },
'nb-NO': { translation: nb_NO },
sk: { translation: sk },
sl: { translation: sl },
sv: { translation: sv },
'zh-Hans': { translation: zh_Hans },
'zh-Hant': { translation: zh_Hant }
};
i18next
.use(initReactI18next)
.init({
// debug: true,
fallbackLng: 'en',
lng: Localization.locale,
interpolation: {
escapeValue: false
},
resources
});
.use(initReactI18next)
.init({
// debug: true,
fallbackLng: 'en',
lng: Localization.locale,
interpolation: {
escapeValue: false
},
resources
});

View File

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

View File

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

View File

@ -11,52 +11,52 @@ import ServerInput from '../components/ServerInput';
import Colors from '../constants/Colors';
const AddServerScreen = () => {
const { t } = useTranslation();
const { t } = useTranslation();
return (
<View style={styles.container}>
<View style={styles.logoContainer}>
<Image
style={styles.logoImage}
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`
/>
</View>
<ServerInput
containerStyle={styles.serverTextContainer}
label={t('addServer.address')}
placeholder='https://jellyfin.org'
t={t}
/>
</View>
);
return (
<View style={styles.container}>
<View style={styles.logoContainer}>
<Image
style={styles.logoImage}
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`
/>
</View>
<ServerInput
containerStyle={styles.serverTextContainer}
label={t('addServer.address')}
placeholder='https://jellyfin.org'
t={t}
/>
</View>
);
};
const styles = StyleSheet.create({
serverTextContainer: {
flex: 1,
alignContent: 'flex-start'
},
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: Colors.backgroundColor
},
logoContainer: {
marginTop: 80,
marginBottom: 48,
alignItems: 'center',
justifyContent: 'center'
},
logoImage: {
width: '90%',
height: undefined,
maxWidth: 481,
maxHeight: 151,
// Aspect ration of the logo
aspectRatio: 3.18253
}
serverTextContainer: {
flex: 1,
alignContent: 'flex-start'
},
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: Colors.backgroundColor
},
logoContainer: {
marginTop: 80,
marginBottom: 48,
alignItems: 'center',
justifyContent: 'center'
},
logoImage: {
width: '90%',
height: undefined,
maxWidth: 481,
maxHeight: 151,
// Aspect ration of the logo
aspectRatio: 3.18253
}
});
export default AddServerScreen;

View File

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

View File

@ -10,30 +10,30 @@ import Colors from '../constants/Colors';
import { SplashScreen } from 'expo';
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.backgroundColor
},
splash: {
flex: 1,
resizeMode: 'contain',
width: undefined,
height: undefined
}
container: {
flex: 1,
backgroundColor: Colors.backgroundColor
},
splash: {
flex: 1,
resizeMode: 'contain',
width: undefined,
height: undefined
}
});
function LoadingScreen() {
return (
<View style={styles.container}>
<Image
style={styles.splash}
source={require('../assets/images/splash.png')}
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`
/>
<ActivityIndicator />
</View>
);
return (
<View style={styles.container}>
<Image
style={styles.splash}
source={require('../assets/images/splash.png')}
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`
/>
<ActivityIndicator />
</View>
);
}
export default LoadingScreen;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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