mirror of
https://github.com/jellyfin/jellyfin-expo.git
synced 2024-11-23 05:59:39 +00:00
Initial commit
This commit is contained in:
commit
d2edb05b08
32
.eslintrc.json
Normal file
32
.eslintrc.json
Normal 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
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules/**/*
|
||||||
|
.expo/*
|
||||||
|
npm-debug.*
|
||||||
|
*.jks
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"eslint.enable": true
|
||||||
|
}
|
1
.watchmanconfig
Normal file
1
.watchmanconfig
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
73
App.js
Normal file
73
App.js
Normal 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
22
__tests__/App-test.js
Normal 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
30
app.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
BIN
assets/fonts/SpaceMono-Regular.ttf
Executable file
BIN
assets/fonts/SpaceMono-Regular.ttf
Executable file
Binary file not shown.
BIN
assets/images/icon.png
Normal file
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
BIN
assets/images/logowhite.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 39 KiB |
BIN
assets/images/splash.png
Executable file
BIN
assets/images/splash.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 45 KiB |
6
babel.config.js
Normal file
6
babel.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = function(api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
};
|
||||||
|
};
|
17
components/TabBarIcon.js
Normal file
17
components/TabBarIcon.js
Normal 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
18
constants/Colors.js
Normal 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
12
constants/Layout.js
Normal 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
5
constants/Storage.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const prefix = 'org.jellyfin.expo';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
Servers: `${prefix}:Servers`
|
||||||
|
}
|
14
navigation/AppNavigator.js
Normal file
14
navigation/AppNavigator.js
Normal 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'
|
||||||
|
}));
|
72
navigation/MainTabNavigator.js
Normal file
72
navigation/MainTabNavigator.js
Normal 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
21210
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal 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
119
screens/AddServerScreen.js
Normal 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
62
screens/HomeScreen.js
Normal 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
|
||||||
|
}
|
||||||
|
});
|
72
screens/ServerLoadingScreen.js
Normal file
72
screens/ServerLoadingScreen.js
Normal 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
39
screens/SettingsScreen.js
Normal 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
50
utils/CachingStorage.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
58
utils/JellyfinValidator.js
Normal file
58
utils/JellyfinValidator.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user