Initial commit

This commit is contained in:
Bill Thornton 2019-08-19 02:47:46 -04:00
commit d2edb05b08
26 changed files with 21956 additions and 0 deletions

32
.eslintrc.json Normal file
View File

@ -0,0 +1,32 @@
{
"env": {
"es6": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended"
],
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parser": "babel-eslint",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 2018,
"sourceType": "module"
},
"plugins": [
"react"
],
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
}
}

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules/**/*
.expo/*
npm-debug.*
*.jks
*.p12
*.key
*.mobileprovision

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"eslint.enable": true
}

1
.watchmanconfig Normal file
View File

@ -0,0 +1 @@
{}

73
App.js Normal file
View File

@ -0,0 +1,73 @@
import React from 'react';
import { Platform, StatusBar, StyleSheet, View } from 'react-native';
import { AppLoading } from 'expo';
import { Asset } from 'expo-asset';
import Constants from 'expo-constants';
import * as Font from 'expo-font';
import { Ionicons } from '@expo/vector-icons';
import AppNavigator from './navigation/AppNavigator';
import Colors from './constants/Colors'
export default class App extends React.Component {
state = {
isLoadingComplete: false,
};
render() {
if (!this.state.isLoadingComplete && !this.props.skipLoadingScreen) {
return (
<AppLoading
startAsync={this._loadResourcesAsync}
onError={this._handleLoadingError}
onFinish={this._handleFinishLoading}
autoHideSplash={false}
/>
);
} else {
return (
<View style={styles.container}>
{Platform.OS === 'ios' && <StatusBar barStyle="light-content" />}
<AppNavigator />
</View>
);
}
}
_loadImagesAsync = () => {
const images = [
require('./assets/images/splash.png'),
require('./assets/images/logowhite.png')
];
return images.map(image => Asset.fromModule(image).downloadAsync());
}
_loadResourcesAsync = async () => {
return Promise.all([
Font.loadAsync({
// This is the font that we are using for our tab bar
...Ionicons.font
}),
...this._loadImagesAsync()
]);
};
_handleLoadingError = error => {
// In this case, you might want to report the error to your error
// reporting service, for example Sentry
console.warn(error);
};
_handleFinishLoading = () => {
this.setState({ isLoadingComplete: true });
};
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.backgroundColor,
// Padding for the StatusBar
paddingTop: Constants.statusBarHeight || 0
}
});

22
__tests__/App-test.js Normal file
View File

@ -0,0 +1,22 @@
import 'react-native';
import React from 'react';
import App from '../App';
import renderer from 'react-test-renderer';
import NavigationTestUtils from 'react-navigation/NavigationTestUtils';
describe('App snapshot', () => {
jest.useFakeTimers();
beforeEach(() => {
NavigationTestUtils.resetInternalState();
});
it('renders the loading screen', async () => {
const tree = renderer.create(<App />).toJSON();
expect(tree).toMatchSnapshot();
});
it('renders the root without loading screen', async () => {
const tree = renderer.create(<App skipLoadingScreen />).toJSON();
expect(tree).toMatchSnapshot();
});
});

30
app.json Normal file
View File

@ -0,0 +1,30 @@
{
"expo": {
"name": "Jellyfin",
"slug": "jellyfin-expo",
"privacy": "unlisted",
"sdkVersion": "34.0.0",
"platforms": [
"ios",
"android"
],
"version": "1.0.0",
"orientation": "portrait",
"primaryColor": "#00a4dc",
"icon": "./assets/images/icon.png",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#101010"
},
"updates": {
"fallbackToCacheTimeout": 0
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true
}
}
}

Binary file not shown.

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
assets/images/logowhite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
assets/images/splash.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

6
babel.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};

17
components/TabBarIcon.js Normal file
View File

@ -0,0 +1,17 @@
import React from 'react';
import { Ionicons } from '@expo/vector-icons';
import Colors from '../constants/Colors';
export default class TabBarIcon extends React.Component {
render() {
return (
<Ionicons
name={this.props.name}
size={26}
style={{ marginBottom: -3 }}
color={this.props.focused ? Colors.tabIconSelected : Colors.tabIconDefault}
/>
);
}
}

18
constants/Colors.js Normal file
View File

@ -0,0 +1,18 @@
const backgroundColor = '#101010';
const headerTintColor = '#fff';
const tintColor = '#00a4dc';
export default {
backgroundColor,
tintColor,
headerTintColor,
tabIconDefault: '#ccc',
tabIconSelected: tintColor,
tabBar: '#fefefe',
errorBackground: 'red',
errorText: '#fff',
warningBackground: '#EAEB5E',
warningText: '#666804',
noticeBackground: tintColor,
noticeText: '#fff',
};

12
constants/Layout.js Normal file
View File

@ -0,0 +1,12 @@
import { Dimensions } from 'react-native';
const width = Dimensions.get('window').width;
const height = Dimensions.get('window').height;
export default {
window: {
width,
height,
},
isSmallDevice: width < 375,
};

5
constants/Storage.js Normal file
View File

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

View File

@ -0,0 +1,14 @@
import React from 'react';
import { createAppContainer, createSwitchNavigator } from 'react-navigation';
import MainTabNavigator from './MainTabNavigator';
import AddServerScreen from '../screens/AddServerScreen';
import ServerLoadingScreen from '../screens/ServerLoadingScreen';
export default createAppContainer(createSwitchNavigator({
ServerLoading: ServerLoadingScreen,
AddServer: AddServerScreen,
Main: MainTabNavigator
}, {
initialRouteName: 'ServerLoading'
}));

View File

@ -0,0 +1,72 @@
import React from 'react';
import { Platform } from 'react-native';
import { createStackNavigator, createBottomTabNavigator } from 'react-navigation';
import Colors from '../constants/Colors'
import TabBarIcon from '../components/TabBarIcon';
import HomeScreen from '../screens/HomeScreen';
import SettingsScreen from '../screens/SettingsScreen';
const defaultNavigationOptions = {
headerStyle: {
backgroundColor: Colors.backgroundColor
},
headerTintColor: Colors.headerTintColor
}
const HomeStack = createStackNavigator({
Home: HomeScreen,
}, {
defaultNavigationOptions
});
HomeStack.navigationOptions = {
tabBarLabel: 'Home',
// eslint-disable-next-line react/display-name
tabBarIcon: ({ focused }) => (
<TabBarIcon
focused={focused}
name={
Platform.OS === 'ios'
? 'ios-tv'
: 'md-tv'
}
/>
),
};
const SettingsStack = createStackNavigator({
Settings: SettingsScreen,
}, {
defaultNavigationOptions
});
SettingsStack.navigationOptions = {
tabBarLabel: 'Settings',
// eslint-disable-next-line react/display-name
tabBarIcon: ({ focused }) => (
<TabBarIcon
focused={focused}
name={
Platform.OS === 'ios'
? 'ios-options'
: 'md-options'
}
/>
),
};
export default createBottomTabNavigator({
HomeStack,
SettingsStack,
}, {
tabBarOptions: {
activeTintColor: Colors.tabIconSelected,
inactiveTintColor: Colors.tabIconDefault,
style: {
backgroundColor: Colors.backgroundColor
},
// Force toolbar label to be under the icon
adaptive: false
}
});

21210
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"eject": "expo eject",
"test": "node ./node_modules/jest/bin/jest.js --watchAll"
},
"jest": {
"preset": "jest-expo"
},
"dependencies": {
"expo": "^34.0.1",
"expo-asset": "^6.0.0",
"expo-constants": "^6.0.0",
"expo-font": "^6.0.1",
"react": "16.8.3",
"react-native": "https://github.com/expo/react-native/archive/sdk-34.0.0.tar.gz",
"react-native-gesture-handler": "^1.3.0",
"react-native-reanimated": "^1.1.0",
"react-navigation": "^3.0.9",
"url-parse": "^1.4.7"
},
"devDependencies": {
"babel-eslint": "^10.0.2",
"babel-preset-expo": "^5.0.0",
"eslint": "^6.1.0",
"eslint-plugin-react": "^7.14.3",
"expo-cli": "^3.0.9",
"jest-expo": "^32.0.0"
},
"private": true
}

119
screens/AddServerScreen.js Normal file
View File

@ -0,0 +1,119 @@
import React from 'react';
import { ActivityIndicator, Button, Image, Platform, StyleSheet, TextInput, View } from 'react-native';
import Colors from '../constants/Colors';
import StorageKeys from '../constants/Storage';
import CachingStorage from '../utils/CachingStorage';
import JellyfinValidator from '../utils/JellyfinValidator';
const DEFAULT_PORT = 8096;
const sanitizeHost = (url = '') => url.trim();
const sanitizePort = (port = '') => {
if (port === '') {
return '';
}
port = Number.parseInt(port, 10);
if (Number.isNaN(port)) {
return DEFAULT_PORT;
}
return port;
}
export default class AddServerScreen extends React.Component {
state = {
host: '',
port: `${DEFAULT_PORT}`,
isValidating: false
}
async onAddServer() {
const { host, port } = this.state;
console.log('add server', host, port);
if (host && port) {
this.setState({ isValidating: true });
// Parse the entered url
const url = JellyfinValidator.parseUrl(host, port);
console.log('parsed url', url);
// Validate the server is available
const isServerValid = await JellyfinValidator.validate({ url });
console.log(`Server is ${isServerValid ? '' : 'not '}valid`);
if (!isServerValid) {
this.setState({ isValidating: false });
return;
}
// Save the server details to app storage
await CachingStorage.getInstance().setItem(StorageKeys.Servers, [{
url
}]);
// Navigate to the main screen
this.props.navigation.navigate('Main');
}
}
render() {
return (
<View style={styles.container}>
<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`
/>
<TextInput
style={styles.serverTextInput}
autoCapitalize='none'
autoCorrect={false}
autoCompleteType='off'
autoFocus={true}
keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
textContentType='URL'
value={this.state.host}
onChangeText={text => this.setState({ host: sanitizeHost(text) })}
/>
<TextInput
style={styles.serverTextInput}
keyboardType='number-pad'
maxLength={5}
value={this.state.port}
onChangeText={text => this.setState({ port: `${sanitizePort(text)}` })}
/>
<Button
title='Add Server'
color={Colors.tintColor}
onPress={() => this.onAddServer()}
/>
{this.state.isValidating ? (<ActivityIndicator />) : null}
<View style={styles.spacer} />
</View>
)
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
backgroundColor: Colors.backgroundColor
},
logoImage: {
flex: 1,
width: '80%',
height: null,
resizeMode: 'contain'
},
serverTextInput: {
fontSize: 20,
margin: 4,
width: '100%',
borderRadius: 2,
padding: 2,
backgroundColor: '#292929',
color: '#fff'
},
spacer: {
flex: 1
}
});

62
screens/HomeScreen.js Normal file
View File

@ -0,0 +1,62 @@
import React from 'react';
import { StyleSheet, View, WebView } from 'react-native';
import Colors from '../constants/Colors';
import StorageKeys from '../constants/Storage';
import CachingStorage from '../utils/CachingStorage';
import JellyfinValidator from '../utils/JellyfinValidator';
// Loading component rendered in webview to avoid flash of white
const loading = () => (
<View style={styles.container} />
);
export default class HomeScreen extends React.Component {
state = {
server: null
};
static navigationOptions = {
header: null
};
async bootstrapAsync() {
let server = await CachingStorage.getInstance().getItem(StorageKeys.Servers);
if (server.length > 0) {
server = server[0];
}
this.setState({ server });
}
componentDidMount() {
this.bootstrapAsync();
}
render() {
if (!this.state.server || !this.state.server.url) {
return loading();
}
return (
<WebView
source={{ uri: JellyfinValidator.getServerUrl(this.state.server) }}
style={styles.container}
// Display loading indicator
startInLoadingState={true}
renderLoading={loading}
// Media playback options to fix video player
allowsInlineMediaPlayback={true}
mediaPlaybackRequiresUserAction={false}
// Use WKWebView on iOS
useWebKit={true}
/>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.backgroundColor
}
});

View File

@ -0,0 +1,72 @@
import React from 'react';
import { ActivityIndicator, Image, StyleSheet, View } from 'react-native';
import { SplashScreen } from 'expo';
import Colors from '../constants/Colors';
import StorageKeys from '../constants/Storage';
import CachingStorage from '../utils/CachingStorage';
import JellyfinValidator from '../utils/JellyfinValidator';
export default class ServerLoadingScreen extends React.Component {
state = { areResourcesReady: false };
constructor(props) {
super(props);
SplashScreen.preventAutoHide();
}
async getServers() {
return await CachingStorage.getInstance().getItem(StorageKeys.Servers);
}
async bootstrapAsync() {
const servers = await this.getServers();
const hasServer = !!servers && servers.length > 0;
console.info('servers', servers, hasServer);
if (hasServer) {
const activeServer = servers[0];
// Validate the server is online and is a Jellyfin server
const isServerValid = await JellyfinValidator.validate(activeServer);
console.log('active server', activeServer, isServerValid);
// TODO: Handle invalid server here
}
// Ensure the splash screen is hidden
SplashScreen.hide();
// Navigate to the appropriate screen
this.props.navigation.navigate(hasServer ? 'Main' : 'AddServer');
}
componentDidMount() {
this.bootstrapAsync();
}
render() {
if (!this.state.areResourcesReady) {
return null;
}
return (
<View style={styles.container}>
<Image
style={{ flex: 1, resizeMode: 'contain', width: undefined, height: undefined }}
source={require('../assets/images/splash.png')}
onLoadEnd={() => {
// wait for image's content to fully load [`Image#onLoadEnd`] (https://facebook.github.io/react-native/docs/image#onloadend)
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>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.backgroundColor
}
});

39
screens/SettingsScreen.js Normal file
View File

@ -0,0 +1,39 @@
import React from 'react';
import { AsyncStorage, Button, StyleSheet, View } from 'react-native';
import Colors from '../constants/Colors';
import StorageKeys from '../constants/Storage';
import CachingStorage from '../utils/CachingStorage';
export default class SettingsScreen extends React.Component {
static navigationOptions = {
title: 'Settings',
};
async clearStorage() {
// Remove all storage items used in the app
await AsyncStorage.multiRemove(Object.values(StorageKeys));
// Reset the storage cache
CachingStorage.instance = null;
// Navigate to the loading screen
this.props.navigation.navigate('ServerLoading');
}
render() {
return (
<View style={styles.container}>
<Button
title='Clear Storage'
onPress={() => this.clearStorage()}
/>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.backgroundColor
}
});

50
utils/CachingStorage.js Normal file
View File

@ -0,0 +1,50 @@
import { AsyncStorage } from 'react-native';
export default class CachingStorage {
static instance = null;
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;
}
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);
}
// 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()`');
}
console.debug(`CachingStorage: Saving value to device storage for ${key}`);
await AsyncStorage.setItem(key, JSON.stringify(item));
this.cache.set(key, item);
}
}

View File

@ -0,0 +1,58 @@
/* globals fetch */
import Url from 'url-parse';
export default class JellyfinValidator {
static parseUrl(host = '', port = '') {
if (!host) {
throw new Error('host cannot be blank')
}
// Parsing seems to fail if a protocol is not set
if (!host.startsWith('http://') && !host.startsWith('https://')) {
host = `http://${host}`;
}
// Parse the host as a url
const url = new Url(host);
// Override the port if provided
if (port) {
url.set('port', port);
}
return url;
}
static getServerUrl(server = {}) {
if (!server || !server.url || !server.url.origin) {
throw new Error(`Cannot get server url for invalid server ${server}`)
}
return `${server.url.origin}${server.url.pathname}`;
}
static async validate(server = {}) {
// Does the server have a valid url?
if (!server.url || !server.url.origin) {
return false;
}
const serverUrl = this.getServerUrl(server);
const infoUrl = `${serverUrl}/system/info/public`;
console.log('info url', infoUrl);
// Try to fetch the server's public info
try {
const response = await fetch(infoUrl);
const responseJson = await response.json();
console.log('response', responseJson);
// 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 true;
}
}
return responseJson.ProductName === 'Jellyfin Server';
} catch(err) {
return false;
}
}
}