= ({Text, Symbol}) => {
16 | const navigation = useNavigation();
17 |
18 | return (
19 | <>
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | >
28 | );
29 | };
30 |
31 | const styles = StyleSheet.create({
32 | container: {
33 | flex: 1,
34 | flexDirection: 'row',
35 | backgroundColor: 'white',
36 | alignItems: 'center',
37 | justifyContent: 'center',
38 | height: 200,
39 | },
40 | text: {
41 | color: colours.grey['200'],
42 | fontSize: 42,
43 | fontWeight: '100',
44 | textAlign: 'center',
45 | },
46 | });
47 |
48 | export default HeaderGeneric;
49 | export {HeaderGeneric};
50 |
--------------------------------------------------------------------------------
/app/components/Settings/SwitchWithDescription.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Dimensions, StyleSheet, Text, View} from 'react-native';
3 |
4 | import {colours} from '@boum/constants';
5 |
6 | const width = Dimensions.get('window').width;
7 |
8 | const SwitchWithDescription = ({
9 | title,
10 | description,
11 | children,
12 | }: {
13 | title: string;
14 | description: string;
15 | children: JSX.Element;
16 | }) => {
17 | return (
18 |
19 | {title}
20 | {description}
21 | {children}
22 |
23 | );
24 | };
25 |
26 | const styles = StyleSheet.create({
27 | container: {
28 | backgroundColor: colours.black,
29 | paddingHorizontal: 12,
30 | justifyContent: 'center',
31 | paddingVertical: 12,
32 | },
33 | buttonContainer: {alignItems: 'center'},
34 | title: {
35 | color: colours.white,
36 | paddingTop: 20,
37 | fontSize: 24,
38 | textAlign: 'left',
39 | fontFamily: 'Inter-Bold',
40 | maxWidth: width * 0.8,
41 | },
42 | description: {
43 | color: colours.grey['100'],
44 | fontSize: 16,
45 | maxWidth: width * 0.8,
46 | fontFamily: 'Inter-Regular',
47 | },
48 | });
49 |
50 | export {SwitchWithDescription};
51 |
--------------------------------------------------------------------------------
/android/app/src/main/java/de/eindm/boum/newarchitecture/components/MainComponentsRegistry.java:
--------------------------------------------------------------------------------
1 | package de.eindm.boum.newarchitecture.components;
2 |
3 | import com.facebook.jni.HybridData;
4 | import com.facebook.proguard.annotations.DoNotStrip;
5 | import com.facebook.react.fabric.ComponentFactory;
6 | import com.facebook.soloader.SoLoader;
7 |
8 | /**
9 | * Class responsible to load the custom Fabric Components. This class has native methods and needs a
10 | * corresponding C++ implementation/header file to work correctly (already placed inside the jni/
11 | * folder for you).
12 | *
13 | * Please note that this class is used ONLY if you opt-in for the New Architecture (see the
14 | * `newArchEnabled` property). Is ignored otherwise.
15 | */
16 | @DoNotStrip
17 | public class MainComponentsRegistry {
18 | static {
19 | SoLoader.loadLibrary("fabricjni");
20 | }
21 |
22 | @DoNotStrip private final HybridData mHybridData;
23 |
24 | @DoNotStrip
25 | private native HybridData initHybrid(ComponentFactory componentFactory);
26 |
27 | @DoNotStrip
28 | private MainComponentsRegistry(ComponentFactory componentFactory) {
29 | mHybridData = initHybrid(componentFactory);
30 | }
31 |
32 | @DoNotStrip
33 | public static MainComponentsRegistry register(ComponentFactory componentFactory) {
34 | return new MainComponentsRegistry(componentFactory);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/components/Artist/ArtistItemsFooter.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Dimensions, StyleSheet, Text, View} from 'react-native';
3 |
4 | import {colours, sizes} from '@boum/constants';
5 | import {getHourMinutes} from '@boum/lib/helper/helper';
6 | import {LibraryItemList, MediaItem} from '@boum/types';
7 |
8 | const width = Dimensions.get('window').width;
9 |
10 | type ArtistItemsFooterProps = {
11 | item: MediaItem;
12 | artistItems: LibraryItemList;
13 | };
14 |
15 | class ArtistItemsFooter extends React.PureComponent {
16 | runTime = getHourMinutes(this.props.item.RunTimeTicks);
17 |
18 | render() {
19 | return (
20 |
21 |
22 | {this.props.artistItems.TotalRecordCount}{' '}
23 | {this.props.artistItems.TotalRecordCount > 1 ? 'Albums' : 'Album'} •{' '}
24 | {this.runTime}
25 |
26 |
27 | );
28 | }
29 | }
30 |
31 | const artistItemsFooter = StyleSheet.create({
32 | container: {
33 | paddingBottom: width * 0.05,
34 | backgroundColor: colours.black,
35 | paddingLeft: sizes.marginListX,
36 | },
37 | text: {
38 | fontSize: sizes.fontSizePrimary,
39 | fontWeight: sizes.fontWeightPrimary,
40 | color: colours.white,
41 | lineHeight: 28,
42 | },
43 | });
44 |
45 | export {ArtistItemsFooter};
46 |
--------------------------------------------------------------------------------
/app/components/Player/PlayerImage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Dimensions, StyleSheet, View} from 'react-native';
3 | import FastImage from 'react-native-fast-image';
4 |
5 | const width = Dimensions.get('window').width;
6 |
7 | type PlayerAlbumImageProps = {
8 | artwork: string;
9 | };
10 |
11 | const PlayerAlbumImage: React.FC = ({artwork}) => {
12 | return (
13 |
14 |
31 |
32 | );
33 | };
34 |
35 | const styles = StyleSheet.create({
36 | container: {
37 | width: width * 0.8,
38 | height: width * 0.8,
39 | alignSelf: 'center',
40 | borderRadius: 10,
41 | shadowColor: '#2a2a2a',
42 | shadowOffset: {
43 | width: 0,
44 | height: 6,
45 | },
46 | shadowOpacity: 0.39,
47 | shadowRadius: 8.3,
48 | elevation: 13,
49 | },
50 | image: {
51 | alignSelf: 'center',
52 | },
53 | });
54 |
55 | export default PlayerAlbumImage;
56 |
--------------------------------------------------------------------------------
/app/hooks/useGetOfflineItems.ts:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from 'react';
2 |
3 | import {DbService} from '@boum/lib/db/Service';
4 | import {useStore} from '@boum/hooks';
5 | import {OfflineItem, TableName} from '@boum/types';
6 |
7 | const getOfflineItems = async (dbService: DbService) => {
8 | return new Promise(async function (resolve, reject) {
9 | const db = await dbService.getDBConnection();
10 |
11 | const items = await dbService
12 | .readParentEntries(db, TableName.ParentItems)
13 | .catch(err => reject(`Couldn't get data from DB: ${err}`));
14 |
15 | let downloadItems: Array = [];
16 |
17 | items.forEach(async (item, index) => {
18 | const children = await dbService.getChildrenEntriesForParent(db, item.id);
19 |
20 | downloadItems.push({
21 | id: item.id,
22 | name: item.name,
23 | metadata: item.metadata,
24 | children: children,
25 | });
26 | if (items.length === index + 1) {
27 | resolve(downloadItems);
28 | }
29 | });
30 | });
31 | };
32 |
33 | const useGetDownloadItems = () => {
34 | const [items, setItems] = useState(false);
35 | const dbService = useStore.getState().dbService;
36 |
37 | useEffect(() => {
38 | async function getItems() {
39 | getOfflineItems(dbService).then(res => setItems(res));
40 | }
41 | getItems();
42 | }, []);
43 |
44 | return items;
45 | };
46 |
47 | export default useGetDownloadItems;
48 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/issue report.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: File a bug report
3 | title: '[Bug]: '
4 | labels: ['bug', 'triage']
5 | assignees:
6 | - henniaufmrenni
7 | body:
8 | - type: markdown
9 | attributes:
10 | value: |
11 | Thanks for taking the time to fill out this bug report.
12 | - type: textarea
13 | id: what-happened
14 | attributes:
15 | label: What happened?
16 | description: Also tell us, what did you expect to happen?
17 | placeholder: Tell us what you see!
18 | value: 'A bug happened!'
19 | validations:
20 | required: true
21 | - type: input
22 | id: version
23 | attributes:
24 | label: Version
25 | description: What version of our software are you running?
26 | validations:
27 | required: true
28 | - type: textarea
29 | id: device
30 | attributes:
31 | label: What device and Android version are you seeing the problem on?
32 | validations:
33 | required: true
34 | - type: checkboxes
35 | id: terms
36 | attributes:
37 | label: Understanding that boum is open-source software
38 | description: boum is open-source software developed in my free-time and provided for free, as is without any liability. I'm happy to fix bugs and add features that align with the project's goals, but that does not result in the entitlement to demand this.
39 | options:
40 | - label: I understand
41 | required: true
42 |
--------------------------------------------------------------------------------
/app/stacks/RootStack.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {
4 | BookScreen,
5 | DownloadsScreen,
6 | ListManagerScreen,
7 | LoginScreen,
8 | PlayerScreen,
9 | QueueScreen,
10 | SettingsScreen,
11 | VideoScreen,
12 | } from '@boum/screens';
13 | import BottomNavigationStack from '@boum/stacks/BottomNavigationStack';
14 | import {createNativeStackNavigator} from '@react-navigation/native-stack';
15 |
16 | const RootStack: React.FC = () => {
17 | const RootStack = createNativeStackNavigator();
18 | return (
19 | <>
20 |
23 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | >
37 | );
38 | };
39 |
40 | export default RootStack;
41 |
--------------------------------------------------------------------------------
/android/app/src/main/java/de/eindm/boum/MainActivity.java:
--------------------------------------------------------------------------------
1 | package de.eindm.boum;
2 |
3 | import com.facebook.react.ReactActivity;
4 | import com.facebook.react.ReactActivityDelegate;
5 | import com.facebook.react.ReactRootView;
6 |
7 | public class MainActivity extends ReactActivity {
8 |
9 | /**
10 | * Returns the name of the main component registered from JavaScript. This is used to schedule
11 | * rendering of the component.
12 | */
13 | @Override
14 | protected String getMainComponentName() {
15 | return "boum";
16 | }
17 |
18 | /**
19 | * Returns the instance of the {@link ReactActivityDelegate}. There the RootView is created and
20 | * you can specify the rendered you wish to use (Fabric or the older renderer).
21 | */
22 | @Override
23 | protected ReactActivityDelegate createReactActivityDelegate() {
24 | return new MainActivityDelegate(this, getMainComponentName());
25 | }
26 |
27 | public static class MainActivityDelegate extends ReactActivityDelegate {
28 | public MainActivityDelegate(ReactActivity activity, String mainComponentName) {
29 | super(activity, mainComponentName);
30 | }
31 |
32 | @Override
33 | protected ReactRootView createRootView() {
34 | ReactRootView reactRootView = new ReactRootView(getContext());
35 | // If you opted-in for the New Architecture, we enable the Fabric Renderer.
36 | reactRootView.setIsFabric(BuildConfig.IS_NEW_ARCHITECTURE_ENABLED);
37 | return reactRootView;
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/ios/Podfile:
--------------------------------------------------------------------------------
1 | require_relative '../node_modules/react-native/scripts/react_native_pods'
2 | require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
3 |
4 | platform :ios, '11.0'
5 | install! 'cocoapods', :deterministic_uuids => false
6 |
7 | target 'boum' do
8 | config = use_native_modules!
9 |
10 | # Flags change depending on the env values.
11 | flags = get_default_flags()
12 |
13 | use_react_native!(
14 | :path => config[:reactNativePath],
15 | # to enable hermes on iOS, change `false` to `true` and then install pods
16 | :hermes_enabled => flags[:hermes_enabled],
17 | :fabric_enabled => flags[:fabric_enabled],
18 | # An absolute path to your application root.
19 | :app_path => "#{Pod::Config.instance.installation_root}/.."
20 | )
21 |
22 | pod 'react-native-netinfo', :path => '../node_modules/@react-native-community/netinfo'
23 |
24 | pod 'RNFS', :path => '../node_modules/react-native-fs'
25 |
26 | pod 'react-native-splash-screen', :path => '../node_modules/react-native-splash-screen'
27 |
28 | target 'boumTests' do
29 | inherit! :complete
30 | # Pods for testing
31 | end
32 |
33 | # Enables Flipper.
34 | #
35 | # Note that if you have use_frameworks! enabled, Flipper will not work and
36 | # you should disable the next line.
37 | use_flipper!()
38 |
39 | post_install do |installer|
40 | react_native_post_install(installer)
41 | __apply_Xcode_12_5_M1_post_install_workaround(installer)
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/app/components/Search/Searchbar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {StyleSheet, Text, TextInput, View} from 'react-native';
3 |
4 | import {colours} from '@boum/constants';
5 | import {useSearchStore} from '@boum/hooks';
6 |
7 | const SearchBar = () => {
8 | const searchInput = useSearchStore(state => state.searchInput);
9 | const setSearchInput = useSearchStore(state => state.setSearchInput);
10 |
11 | return (
12 |
13 | Search
14 | setSearchInput(input)}
17 | value={searchInput}
18 | placeholder="Search"
19 | autoCorrect={false}
20 | placeholderTextColor={colours.grey[400]}
21 | />
22 |
23 | );
24 | };
25 |
26 | const styles = StyleSheet.create({
27 | title: {
28 | fontFamily: 'Inter-Bold',
29 | color: colours.white,
30 | paddingHorizontal: 12,
31 | fontSize: 30,
32 | },
33 | container: {
34 | backgroundColor: colours.black,
35 | color: colours.white,
36 | },
37 | input: {
38 | backgroundColor: colours.black,
39 | width: '95%',
40 | alignSelf: 'center',
41 | borderColor: colours.grey['500'],
42 | borderWidth: 1,
43 | fontSize: 16,
44 | marginTop: 10,
45 | padding: 12,
46 | height: 45,
47 | fontFamily: 'Inter-Regular',
48 | borderRadius: 7,
49 | color: colours.white,
50 | },
51 | });
52 |
53 | export {SearchBar};
54 |
--------------------------------------------------------------------------------
/android/app/_BUCK:
--------------------------------------------------------------------------------
1 | # To learn about Buck see [Docs](https://buckbuild.com/).
2 | # To run your application with Buck:
3 | # - install Buck
4 | # - `npm start` - to start the packager
5 | # - `cd android`
6 | # - `keytool -genkey -v -keystore keystores/debug.keystore -storepass android -alias androiddebugkey -keypass android -dname "CN=Android Debug,O=Android,C=US"`
7 | # - `./gradlew :app:copyDownloadableDepsToLibs` - make all Gradle compile dependencies available to Buck
8 | # - `buck install -r android/app` - compile, install and run application
9 | #
10 |
11 | load(":build_defs.bzl", "create_aar_targets", "create_jar_targets")
12 |
13 | lib_deps = []
14 |
15 | create_aar_targets(glob(["libs/*.aar"]))
16 |
17 | create_jar_targets(glob(["libs/*.jar"]))
18 |
19 | android_library(
20 | name = "all-libs",
21 | exported_deps = lib_deps,
22 | )
23 |
24 | android_library(
25 | name = "app-code",
26 | srcs = glob([
27 | "src/main/java/**/*.java",
28 | ]),
29 | deps = [
30 | ":all-libs",
31 | ":build_config",
32 | ":res",
33 | ],
34 | )
35 |
36 | android_build_config(
37 | name = "build_config",
38 | package = "de.eindm.boum",
39 | )
40 |
41 | android_resource(
42 | name = "res",
43 | package = "de.eindm.boum",
44 | res = "src/main/res",
45 | )
46 |
47 | android_binary(
48 | name = "app",
49 | keystore = "//android/keystores:debug",
50 | manifest = "src/main/AndroidManifest.xml",
51 | package_type = "debug",
52 | deps = [
53 | ":app-code",
54 | ],
55 | )
56 |
--------------------------------------------------------------------------------
/app/components/Home/HeaderHome.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {StyleSheet, Text, TouchableOpacity, View} from 'react-native';
3 | import Icon from 'react-native-vector-icons/Ionicons';
4 |
5 | import {colours, sizes} from '@boum/constants';
6 | import {capitalizeFirstLetter} from '@boum/lib/helper/helper';
7 | import {Session} from '@boum/types';
8 | import {NavigationProp} from '@react-navigation/native';
9 |
10 | type HeaderHomeProps = {
11 | navigation: NavigationProp;
12 | session: Session;
13 | };
14 |
15 | const HeaderHome: React.FC = ({navigation, session}) => {
16 | return (
17 |
18 | {session ? (
19 |
20 | Welcome {capitalizeFirstLetter(session.username)}
21 |
22 | ) : null}
23 | navigation.navigate('Settings')}>
24 |
25 |
26 |
27 |
28 |
29 | );
30 | };
31 |
32 | const styles = StyleSheet.create({
33 | container: {
34 | flex: 1,
35 | flexDirection: 'row',
36 | backgroundColor: colours.black,
37 | alignItems: 'center',
38 | justifyContent: 'space-between',
39 | paddingHorizontal: sizes.marginListX,
40 | paddingTop: 35,
41 | paddingBottom: sizes.marginListY / 2,
42 | },
43 | text: {
44 | color: 'white',
45 | fontSize: 22,
46 | fontFamily: 'Inter-SemiBold',
47 | },
48 | });
49 |
50 | export {HeaderHome};
51 |
--------------------------------------------------------------------------------
/app/hooks/useInitializeSession.ts:
--------------------------------------------------------------------------------
1 | import {useEffect} from 'react';
2 |
3 | import {useStore} from '@boum/hooks';
4 | import {retrieveEncryptedValue} from '@boum/lib/encryptedStorage/encryptedStorage';
5 |
6 | const useIntializeSession = async () => {
7 | const playerIsSetup = useStore(state => state.playerIsSetup);
8 |
9 | useEffect(() => {
10 | async function init() {
11 | await retrieveEncryptedValue('user_session')
12 | .then(async response => {
13 | const json = JSON.parse(response);
14 | useStore.setState({session: json});
15 | useStore.setState({gotLoginStatus: true});
16 | })
17 | .catch(error => {
18 | useStore.setState({gotLoginStatus: true});
19 | console.error('No user session', error);
20 | });
21 |
22 | await retrieveEncryptedValue('offline_mode')
23 | .then(response => {
24 | if (response === 'true') {
25 | useStore.setState({offlineMode: true});
26 | }
27 | })
28 | .catch(error => {
29 | console.error('No Offline mode set', error);
30 | });
31 |
32 | await retrieveEncryptedValue('selected_storage_location')
33 | .then(async response => {
34 | useStore.setState({selectedStorageLocation: response});
35 | })
36 | .catch(error => {
37 | console.error('No storage location set', error);
38 | useStore.setState({selectedStorageLocation: 'DocumentDirectory'});
39 | });
40 | }
41 | init();
42 | }, [playerIsSetup]);
43 | };
44 |
45 | export {useIntializeSession};
46 |
--------------------------------------------------------------------------------
/app/components/Settings/ButtonBoum.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Dimensions,
4 | StyleSheet,
5 | Text,
6 | TouchableOpacity,
7 | View,
8 | } from 'react-native';
9 |
10 | import {colours} from '@boum/constants';
11 |
12 | const width = Dimensions.get('window').width;
13 |
14 | type Props = {
15 | onPress: () => void;
16 | title: string;
17 | isDisabled?: boolean;
18 | };
19 |
20 | const ButtonBoum = ({onPress, title, isDisabled}: Props) => {
21 | return (
22 | <>
23 | {isDisabled ? (
24 |
25 |
26 | {title}
27 |
28 |
29 | ) : (
30 |
31 |
32 | {title}
33 |
34 |
35 | )}
36 | >
37 | );
38 | };
39 |
40 | const styles = StyleSheet.create({
41 | buttonContainer: {
42 | width: width * 0.85,
43 | height: 50,
44 | borderRadius: 25,
45 | paddingLeft: 20,
46 | paddingRight: 20,
47 | marginTop: 10,
48 | marginBottom: 10,
49 | alignItems: 'center',
50 | alignSelf: 'center',
51 | justifyContent: 'center',
52 | borderWidth: 2,
53 | borderColor: colours.grey['700'],
54 | backgroundColor: colours.black,
55 | },
56 | buttonText: {
57 | fontSize: 20,
58 | fontWeight: '600',
59 | },
60 | });
61 |
62 | export default ButtonBoum;
63 | export {ButtonBoum};
64 |
--------------------------------------------------------------------------------
/app/components/Cast/CastButton.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect} from 'react';
2 | import {StyleSheet} from 'react-native';
3 | import {CastButton as CastFrameworkButton} from 'react-native-google-cast';
4 | import TrackPlayer from 'react-native-track-player';
5 |
6 | import {useStore} from '@boum/hooks';
7 | import {Session, TrackBoum} from '@boum/types';
8 |
9 | type CastButtonProps = {
10 | session: Session;
11 | queue: Array;
12 | };
13 |
14 | const CastButton: React.FC = ({session, queue}) => {
15 | const service = useStore(state => state.castService);
16 | const client = useStore(state => state.castClient);
17 | const device = useStore(state => state.castDevice);
18 |
19 | useEffect(() => {
20 | async function getQueue() {
21 | if (client !== null && device) {
22 | await service
23 | .mapTrackPlayerQueueToCast(queue, session, 0)
24 | .then(mappedQueue => {
25 | client
26 | .loadMedia({
27 | autoplay: true,
28 | queueData: mappedQueue,
29 | })
30 | .catch(err => console.log(err))
31 | .then(() => {
32 | TrackPlayer.pause();
33 | TrackPlayer.reset();
34 | });
35 | });
36 | }
37 | }
38 | getQueue();
39 | }, [client, device, service, queue, session]);
40 | return ;
41 | };
42 |
43 | const styles = StyleSheet.create({
44 | button: {
45 | width: 24,
46 | height: 24,
47 | tintColor: 'white',
48 | },
49 | });
50 |
51 | export {CastButton};
52 |
--------------------------------------------------------------------------------
/android/app/src/main/jni/MainApplicationTurboModuleManagerDelegate.cpp:
--------------------------------------------------------------------------------
1 | #include "MainApplicationTurboModuleManagerDelegate.h"
2 | #include "MainApplicationModuleProvider.h"
3 |
4 | namespace facebook {
5 | namespace react {
6 |
7 | jni::local_ref
8 | MainApplicationTurboModuleManagerDelegate::initHybrid(
9 | jni::alias_ref) {
10 | return makeCxxInstance();
11 | }
12 |
13 | void MainApplicationTurboModuleManagerDelegate::registerNatives() {
14 | registerHybrid({
15 | makeNativeMethod(
16 | "initHybrid", MainApplicationTurboModuleManagerDelegate::initHybrid),
17 | makeNativeMethod(
18 | "canCreateTurboModule",
19 | MainApplicationTurboModuleManagerDelegate::canCreateTurboModule),
20 | });
21 | }
22 |
23 | std::shared_ptr
24 | MainApplicationTurboModuleManagerDelegate::getTurboModule(
25 | const std::string name,
26 | const std::shared_ptr jsInvoker) {
27 | // Not implemented yet: provide pure-C++ NativeModules here.
28 | return nullptr;
29 | }
30 |
31 | std::shared_ptr
32 | MainApplicationTurboModuleManagerDelegate::getTurboModule(
33 | const std::string name,
34 | const JavaTurboModule::InitParams ¶ms) {
35 | return MainApplicationModuleProvider(name, params);
36 | }
37 |
38 | bool MainApplicationTurboModuleManagerDelegate::canCreateTurboModule(
39 | std::string name) {
40 | return getTurboModule(name, nullptr) != nullptr ||
41 | getTurboModule(name, {.moduleName = name}) != nullptr;
42 | }
43 |
44 | } // namespace react
45 | } // namespace facebook
46 |
--------------------------------------------------------------------------------
/android/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/android/app/src/main/jni/Android.mk:
--------------------------------------------------------------------------------
1 | THIS_DIR := $(call my-dir)
2 |
3 | include $(REACT_ANDROID_DIR)/Android-prebuilt.mk
4 |
5 | # If you wish to add a custom TurboModule or Fabric component in your app you
6 | # will have to include the following autogenerated makefile.
7 | # include $(GENERATED_SRC_DIR)/codegen/jni/Android.mk
8 | include $(CLEAR_VARS)
9 |
10 | LOCAL_PATH := $(THIS_DIR)
11 |
12 | # You can customize the name of your application .so file here.
13 | LOCAL_MODULE := boum_appmodules
14 |
15 | LOCAL_C_INCLUDES := $(LOCAL_PATH)
16 | LOCAL_SRC_FILES := $(wildcard $(LOCAL_PATH)/*.cpp)
17 | LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)
18 |
19 | # If you wish to add a custom TurboModule or Fabric component in your app you
20 | # will have to uncomment those lines to include the generated source
21 | # files from the codegen (placed in $(GENERATED_SRC_DIR)/codegen/jni)
22 | #
23 | # LOCAL_C_INCLUDES += $(GENERATED_SRC_DIR)/codegen/jni
24 | # LOCAL_SRC_FILES += $(wildcard $(GENERATED_SRC_DIR)/codegen/jni/*.cpp)
25 | # LOCAL_EXPORT_C_INCLUDES += $(GENERATED_SRC_DIR)/codegen/jni
26 |
27 | # Here you should add any native library you wish to depend on.
28 | LOCAL_SHARED_LIBRARIES := \
29 | libfabricjni \
30 | libfbjni \
31 | libfolly_futures \
32 | libfolly_json \
33 | libglog \
34 | libjsi \
35 | libreact_codegen_rncore \
36 | libreact_debug \
37 | libreact_nativemodule_core \
38 | libreact_render_componentregistry \
39 | libreact_render_core \
40 | libreact_render_debug \
41 | libreact_render_graphics \
42 | librrc_view \
43 | libruntimeexecutor \
44 | libturbomodulejsijni \
45 | libyoga
46 |
47 | LOCAL_CFLAGS := -DLOG_TAG=\"ReactNative\" -fexceptions -frtti -std=c++17 -Wall
48 |
49 | include $(BUILD_SHARED_LIBRARY)
50 |
--------------------------------------------------------------------------------
/android/settings.gradle:
--------------------------------------------------------------------------------
1 | rootProject.name = 'boum'
2 | apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
3 | include ':app'
4 | includeBuild('../node_modules/react-native-gradle-plugin')
5 |
6 | if (settings.hasProperty("newArchEnabled") && settings.newArchEnabled == "true") {
7 | include(":ReactAndroid")
8 | project(":ReactAndroid").projectDir = file('../node_modules/react-native/ReactAndroid')
9 | }
10 |
11 | include ':@react-native-community_netinfo'
12 | project(':@react-native-community_netinfo').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-community/netinfo/android')
13 | include ':react-native-splash-screen'
14 | project(':react-native-splash-screen').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-splash-screen/android')
15 | include ':react-native-splash-screen'
16 | project(':react-native-splash-screen').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-splash-screen/android')
17 | include ':react-native-splash-screen'
18 | project(':react-native-splash-screen').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-splash-screen/android')
19 | include ':react-native-fs'
20 | project(':react-native-fs').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fs/android')
21 | include ':@react-native-community_netinfo'
22 | project(':@react-native-community_netinfo').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-community/netinfo/android')
23 | include ':react-native-video'
24 | project(':react-native-video').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-video/android')
25 |
--------------------------------------------------------------------------------
/app/components/Artist/ArtistItems.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {StyleSheet, View} from 'react-native';
3 |
4 | import {ArtistItemsFooter, ArtistItemsHeader} from '@boum/components/Artist';
5 | import {AlbumCard} from '@boum/components/Library/AlbumCard';
6 | import {LibraryItemList, NavigationDestination, Session} from '@boum/types';
7 |
8 | type ArtistItemsProps = {
9 | screenItem: any;
10 | items: LibraryItemList;
11 | navigation: any;
12 | text: string;
13 | session: Session;
14 | navigationDestination: NavigationDestination;
15 | };
16 |
17 | const ArtistItems: React.FC = ({
18 | screenItem,
19 | items,
20 | navigation,
21 | text,
22 | session,
23 | navigationDestination,
24 | }) => {
25 | return (
26 | <>
27 | {items !== undefined &&
28 | items.TotalRecordCount !== undefined &&
29 | items.TotalRecordCount >= 1 ? (
30 |
31 | <>
32 |
33 |
34 | {items.Items.map(item => (
35 |
42 | ))}
43 |
44 |
45 | >
46 |
47 | ) : null}
48 | >
49 | );
50 | };
51 |
52 | const artistItems = StyleSheet.create({
53 | container: {
54 | flex: 1,
55 | flexDirection: 'row',
56 | flexWrap: 'wrap',
57 | },
58 | });
59 |
60 | export {ArtistItems};
61 |
--------------------------------------------------------------------------------
/.flowconfig:
--------------------------------------------------------------------------------
1 | [ignore]
2 | ; We fork some components by platform
3 | .*/*[.]android.js
4 |
5 | ; Ignore "BUCK" generated dirs
6 | /\.buckd/
7 |
8 | ; Ignore polyfills
9 | node_modules/react-native/Libraries/polyfills/.*
10 |
11 | ; Flow doesn't support platforms
12 | .*/Libraries/Utilities/LoadingView.js
13 |
14 | .*/node_modules/resolve/test/resolver/malformed_package_json/package\.json$
15 |
16 | [untyped]
17 | .*/node_modules/@react-native-community/cli/.*/.*
18 |
19 | [include]
20 |
21 | [libs]
22 | node_modules/react-native/interface.js
23 | node_modules/react-native/flow/
24 |
25 | [options]
26 | emoji=true
27 |
28 | exact_by_default=true
29 |
30 | format.bracket_spacing=false
31 |
32 | module.file_ext=.js
33 | module.file_ext=.json
34 | module.file_ext=.ios.js
35 |
36 | munge_underscores=true
37 |
38 | module.name_mapper='^react-native/\(.*\)$' -> '/node_modules/react-native/\1'
39 | module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> '/node_modules/react-native/Libraries/Image/RelativeImageStub'
40 |
41 | suppress_type=$FlowIssue
42 | suppress_type=$FlowFixMe
43 | suppress_type=$FlowFixMeProps
44 | suppress_type=$FlowFixMeState
45 |
46 | [lints]
47 | sketchy-null-number=warn
48 | sketchy-null-mixed=warn
49 | sketchy-number=warn
50 | untyped-type-import=warn
51 | nonstrict-import=warn
52 | deprecated-type=warn
53 | unsafe-getters-setters=warn
54 | unnecessary-invariant=warn
55 | signature-verification-failure=warn
56 |
57 | [strict]
58 | deprecated-type
59 | nonstrict-import
60 | sketchy-null
61 | unclear-type
62 | unsafe-getters-setters
63 | untyped-import
64 | untyped-type-import
65 |
66 | [version]
67 | ^0.170.0
68 |
--------------------------------------------------------------------------------
/app/stacks/BottomNavigationStack.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-unstable-nested-components */
2 | import React from 'react';
3 | import Ionicons from 'react-native-vector-icons/Ionicons';
4 |
5 | import {colours} from '@boum/constants';
6 | import HomeStack from '@boum/stacks/HomeStack';
7 | import LibraryStack from '@boum/stacks/LibraryStack';
8 | import SearchStack from '@boum/stacks/SearchStack';
9 | import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
10 |
11 | const BottomNavigationStack: React.FC = () => {
12 | const Tab = createBottomTabNavigator();
13 | return (
14 | ({
17 | animation: 'none',
18 | headerShown: false,
19 | tabBarActiveTintColor: colours.accent,
20 | tabBarInactiveTintColor: colours.white,
21 | tabBarIcon: ({color, size}) => {
22 | let iconName;
23 |
24 | if (route.name === 'Home') {
25 | iconName = 'ios-home';
26 | } else if (route.name === 'Library') {
27 | iconName = 'ios-library';
28 | } else if (route.name === 'Search') {
29 | iconName = 'search';
30 | }
31 |
32 | return ;
33 | },
34 | tabBarStyle: {
35 | height: 60,
36 | paddingHorizontal: 0,
37 | paddingTop: 0,
38 | paddingBottom: 5,
39 | backgroundColor: colours.black,
40 | borderTopWidth: 0,
41 | },
42 | })}>
43 |
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default BottomNavigationStack;
51 |
--------------------------------------------------------------------------------
/.github/workflows/android-debug-pr.yml:
--------------------------------------------------------------------------------
1 | name: build-debug
2 |
3 | on:
4 | pull_request:
5 | types: [opened, reopened, ready_for_review, edited]
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout repository
12 | uses: actions/checkout@v2
13 |
14 | - name: Setup the node environment
15 | uses: actions/setup-node@v2
16 | with:
17 | node-version: '14'
18 | cache: 'yarn'
19 |
20 | - name: Install the dependencies
21 | run: |
22 | yarn
23 | - name: Cache Gradle Wrapper
24 | uses: actions/cache@v2
25 | with:
26 | path: ~/.gradle/wrapper
27 | key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }}
28 |
29 | - name: Cache Gradle Dependencies
30 | uses: actions/cache@v2
31 | with:
32 | path: ~/.gradle/caches
33 | key: ${{ runner.os }}-gradle-caches-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }}
34 | restore-keys: |
35 | ${{ runner.os }}-gradle-caches-
36 | - name: Build debug APK
37 | run: |
38 | yarn android-build-debug
39 | mkdir bin
40 | mkdir bin/32
41 | mkdir bin/64
42 | mv android/app/build/outputs/apk/debug/app-armeabi-v7a-debug.apk bin/32/
43 | mv android/app/build/outputs/apk/debug/app-arm64-v8a-debug.apk bin/64
44 |
45 | - name: 'Upload arm32 Artifact'
46 | uses: actions/upload-artifact@v3
47 | with:
48 | path: bin/32/app-armeabi-v7a-debug.apk
49 | retention-days: 10
50 |
51 | - name: 'Upload arm64 Artifact'
52 | uses: actions/upload-artifact@v3
53 | with:
54 | path: bin/64/app-arm64-v8a-debug.apk
55 | retention-days: 10
56 |
--------------------------------------------------------------------------------
/app/hooks/useGetBooks.ts:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from 'react';
2 |
3 | import {useBooksStore, useStore} from '@boum/hooks';
4 | import addNewItemsToOldObject from '@boum/lib/helper/addNewItemsToOldObject';
5 | import {Session} from '@boum/types';
6 |
7 | const useGetBooks = (session: Session) => {
8 | const jellyfin = useStore.getState().jellyfinClient;
9 | // Infinite Loading
10 | const startIndex = useBooksStore(state => state.itemsPageIndex);
11 |
12 | const [loadedMore, setLoadedMore] = useState(false);
13 | const allAudiobooks = useBooksStore(state => state.allItems);
14 | const setAllAudiobooks = useBooksStore(state => state.setAllItems);
15 |
16 | // Sorting & Filtering
17 | const sortBy = useBooksStore(state => state.sortBy);
18 | const sortOrder = useBooksStore(state => state.sortOrder);
19 |
20 | const [apiResponse, setApiResponse] = useState(false);
21 |
22 | if (apiResponse && !loadedMore) {
23 | const newAlbumItems = addNewItemsToOldObject(
24 | startIndex,
25 | allAudiobooks,
26 | apiResponse,
27 | );
28 | setAllAudiobooks(newAlbumItems);
29 | setLoadedMore(true);
30 | setApiResponse(false);
31 | }
32 |
33 | useEffect(() => {
34 | const getData = async () => {
35 | await jellyfin
36 | .getAllBooks(session, startIndex, sortBy, sortOrder)
37 | .then(data => {
38 | if (!data.allBooksError) {
39 | setApiResponse(data.allBooksData);
40 | setLoadedMore(true);
41 | }
42 | })
43 | .catch(err => {
44 | console.warn('Errror getting audiobooks', err);
45 | });
46 | };
47 | getData();
48 | }, [startIndex, session, sortBy, sortOrder, jellyfin]);
49 |
50 | return {
51 | allAudiobooks,
52 | setLoadedMore,
53 | };
54 | };
55 |
56 | export {useGetBooks};
57 |
--------------------------------------------------------------------------------
/app/lib/audio/PlaybackService.ts:
--------------------------------------------------------------------------------
1 | import TrackPlayer, {Event, State} from 'react-native-track-player';
2 |
3 | let wasPausedByDuck = false;
4 |
5 | const PlaybackService = async (): Promise => {
6 | TrackPlayer.addEventListener(Event.RemotePause, () => {
7 | TrackPlayer.pause();
8 | });
9 |
10 | TrackPlayer.addEventListener(Event.RemotePlay, () => {
11 | TrackPlayer.play();
12 | });
13 |
14 | TrackPlayer.addEventListener(Event.RemoteNext, () => {
15 | TrackPlayer.skipToNext();
16 | });
17 |
18 | TrackPlayer.addEventListener(Event.RemotePrevious, () => {
19 | TrackPlayer.skipToPrevious();
20 | });
21 |
22 | TrackPlayer.addEventListener(Event.RemoteJumpForward, async event => {
23 | const position = (await TrackPlayer.getPosition()) + event.interval;
24 | TrackPlayer.seekTo(position);
25 | });
26 |
27 | TrackPlayer.addEventListener(Event.RemoteJumpBackward, async event => {
28 | const position = (await TrackPlayer.getPosition()) - event.interval;
29 | TrackPlayer.seekTo(position);
30 | });
31 |
32 | TrackPlayer.addEventListener(Event.RemoteSeek, event => {
33 | TrackPlayer.seekTo(event.position);
34 | });
35 |
36 | TrackPlayer.addEventListener(
37 | Event.RemoteDuck,
38 | async ({permanent, paused}) => {
39 | if (permanent) {
40 | TrackPlayer.pause();
41 | return;
42 | }
43 | if (paused) {
44 | const playerState = await TrackPlayer.getState();
45 | wasPausedByDuck = playerState !== State.Paused;
46 | TrackPlayer.pause();
47 | } else {
48 | if (wasPausedByDuck) {
49 | TrackPlayer.play();
50 | wasPausedByDuck = false;
51 | }
52 | }
53 | },
54 | );
55 |
56 | TrackPlayer.addEventListener(Event.PlaybackQueueEnded, () => {
57 | TrackPlayer.skip(0);
58 | });
59 | };
60 |
61 | export {PlaybackService};
62 |
--------------------------------------------------------------------------------
/app/screens/GenresScreen.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {FlatList, StyleSheet, View} from 'react-native';
3 |
4 | import LibraryHeader from '@boum/components/Library/LibraryHeader';
5 | import LibraryListItem from '@boum/components/Library/LibraryListItem';
6 | import {LoadingSpinner} from '@boum/components/Generic';
7 | import {colours} from '@boum/constants';
8 | import {useStore} from '@boum/hooks';
9 | import {NavigationProp} from '@react-navigation/native';
10 |
11 | type GenresScreenProps = {
12 | navigation: NavigationProp;
13 | };
14 |
15 | const GenresScreen: React.FC = ({navigation}) => {
16 | const jellyfin = useStore.getState().jellyfinClient;
17 | const session = useStore(state => state.session);
18 |
19 | const {allGenres, allGenresError, allGenresLoading} =
20 | jellyfin.getAllGenres(session);
21 |
22 | return (
23 |
24 | {!allGenresError && !allGenresLoading && allGenres !== undefined ? (
25 | item.Name}
28 | ListHeaderComponent={
29 |
30 | }
31 | renderItem={({item}) => (
32 |
38 | )}
39 | />
40 | ) : (
41 |
42 | )}
43 |
44 | );
45 | };
46 |
47 | const styles = StyleSheet.create({
48 | container: {
49 | width: '100%',
50 | flexDirection: 'column',
51 | flex: 2,
52 | backgroundColor: colours.black,
53 | },
54 | error: {
55 | textAlign: 'center',
56 | },
57 | });
58 |
59 | export {GenresScreen};
60 |
--------------------------------------------------------------------------------
/app/components/Album/SimilarAlbums.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Dimensions, StyleSheet, Text, View} from 'react-native';
3 | import {ScrollView} from 'react-native-gesture-handler';
4 |
5 | import {AlbumCard} from '@boum/components/Library/AlbumCard';
6 | import {colours, sizes} from '@boum/constants';
7 | import {Session} from '@boum/types';
8 |
9 | const width = Dimensions.get('window').width;
10 |
11 | type Props = {
12 | items: object;
13 | navigation: any;
14 | text: string;
15 | session: Session;
16 | };
17 |
18 | class SimilarAlbums extends React.Component {
19 | render() {
20 | return (
21 | <>
22 | {this.props.items.TotalRecordCount >= 1 ? (
23 | <>
24 |
25 | {this.props.text}
26 |
27 | {this.props.items.Items.map(album => (
28 |
35 | ))}
36 |
37 |
38 | >
39 | ) : null}
40 | >
41 | );
42 | }
43 | }
44 |
45 | const similarAlbums = StyleSheet.create({
46 | container: {
47 | flex: 1,
48 | flexDirection: 'row',
49 | flexWrap: 'wrap',
50 | },
51 | text: {
52 | maxWidth: width * 0.5,
53 | paddingTop: width * 0.08,
54 | paddingBottom: width * 0.05,
55 | paddingLeft: width * 0.05,
56 | paddingRight: width * 0.05,
57 | fontSize: 22,
58 | fontWeight: sizes.fontWeightPrimary,
59 | color: colours.white,
60 | },
61 | });
62 |
63 | export {SimilarAlbums};
64 |
--------------------------------------------------------------------------------
/android/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 |
3 | # IDE (e.g. Android Studio) users:
4 | # Gradle settings configured through the IDE *will override*
5 | # any settings specified in this file.
6 |
7 | # For more details on how to configure your build environment visit
8 | # http://www.gradle.org/docs/current/userguide/build_environment.html
9 |
10 | # Specifies the JVM arguments used for the daemon process.
11 | # The setting is particularly useful for tweaking memory settings.
12 | # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
13 | org.gradle.jvmargs=-Xmx4608m -XX:MaxMetaspaceSize=512m
14 |
15 | # When configured, Gradle will run in incubating parallel mode.
16 | # This option should only be used with decoupled projects. More details, visit
17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
18 | # org.gradle.parallel=true
19 |
20 | # AndroidX package structure to make it clearer which packages are bundled with the
21 | # Android operating system, and which are packaged with your app's APK
22 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
23 | android.useAndroidX=true
24 | # Automatically convert third-party libraries to use AndroidX
25 | android.enableJetifier=true
26 |
27 | # Version of flipper SDK to use with React Native
28 | FLIPPER_VERSION=0.125.0
29 |
30 | # Use this property to specify which architecture you want to build.
31 | # You can also override it from the CLI using
32 | # ./gradlew -PreactNativeArchitectures=x86_64
33 | reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
34 |
35 | # Use this property to enable support to the new architecture.
36 | # This will allow you to use TurboModules and the Fabric render in
37 | # your application. You should enable this flag either if you want
38 | # to write custom TurboModules/Fabric components OR use libraries that
39 | # are providing them.
40 | newArchEnabled=false
41 |
--------------------------------------------------------------------------------
/app/components/ContextMenu/ContextAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {StyleSheet, Text, TouchableOpacity, View} from 'react-native';
3 | import Icon from 'react-native-vector-icons/Ionicons';
4 |
5 | import {colours, sizes} from '@boum/constants';
6 |
7 | type ContextActionProps = {
8 | title: string;
9 | ioniconIcon: string;
10 | action: () => void;
11 | children?: React.ReactNode;
12 | actionStatusMessage?: React.ReactNode;
13 | };
14 |
15 | const ContextAction: React.FC = ({
16 | title,
17 | ioniconIcon,
18 | action,
19 | children,
20 | actionStatusMessage,
21 | }) => {
22 | return (
23 |
24 |
25 |
26 |
27 |
33 | {!children ? (
34 | <>
35 | {' '} {title} {actionStatusMessage}
36 | >
37 | ) : null}
38 |
39 | {children}
40 |
41 |
42 |
43 | );
44 | };
45 | const contextActionStyles = StyleSheet.create({
46 | container: {
47 | paddingHorizontal: sizes.marginListX,
48 | paddingVertical: sizes.marginListX / 2,
49 | alignItems: 'flex-start',
50 | justifyContent: 'center',
51 | flex: 1,
52 | flexDirection: 'row',
53 | },
54 | actionButton: {
55 | flex: 1,
56 | alignSelf: 'center',
57 | },
58 | title: {
59 | color: colours.white,
60 | fontSize: 17,
61 | fontFamily: 'Inter-Medium',
62 | marginLeft: 15,
63 | },
64 | childrenContainer: {
65 | flex: 1,
66 | },
67 | });
68 |
69 | export {ContextAction};
70 |
--------------------------------------------------------------------------------
/app/components/Player/PlaybackSpeedPicker.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {StyleSheet, View} from 'react-native';
3 | import TrackPlayer from 'react-native-track-player';
4 |
5 | import {colours} from '@boum/constants';
6 | import {useStore} from '@boum/hooks';
7 | import {Picker} from '@react-native-picker/picker';
8 |
9 | const PlaybackSpeedPicker = () => {
10 | const setPlaybackSpeedState = useStore(state => state.setPlaybackSpeed);
11 |
12 | return (
13 |
14 |
16 | await TrackPlayer.setRate(itemValue).then(() =>
17 | setPlaybackSpeedState(itemValue),
18 | )
19 | }
20 | enabled={true}
21 | itemStyle={styles.picker}>
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | const styles = StyleSheet.create({
36 | container: {
37 | justifyContent: 'center',
38 | marginHorizontal: 20,
39 | backgroundColor: 'white',
40 | },
41 | text: {
42 | color: colours.white,
43 | fontSize: 16,
44 | textAlign: 'center',
45 | fontFamily: 'Inter-Regular',
46 | },
47 | picker: {
48 | color: colours.white,
49 | width: '100%',
50 | },
51 | item: {
52 | color: colours.black,
53 | backgroundColor: colours.white,
54 | },
55 | });
56 |
57 | export {PlaybackSpeedPicker};
58 |
--------------------------------------------------------------------------------
/app/hooks/useGetAlbum.ts:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from 'react';
2 | import {Blurhash} from 'react-native-blurhash';
3 |
4 | import {useCheckParentIsDownloaded, useStore} from '@boum/hooks';
5 | import {Session} from '@boum/types';
6 |
7 | const useGetAlbum = (
8 | routeItem: object,
9 | routeItemId: string,
10 | session: Session,
11 | ) => {
12 | const jellyfin = useStore.getState().jellyfinClient;
13 | const [albumInfo, setAlbumInfo] = useState(false);
14 | const [averageColorRgb, setAverageColorRgb] = useState('');
15 |
16 | const {albumItems} = jellyfin.getAlbumItems(session, routeItemId);
17 | const {similarAlbums} = jellyfin.getSimilarItems(session, routeItemId);
18 |
19 | const isDownloaded = useCheckParentIsDownloaded(routeItemId);
20 |
21 | useEffect(() => {
22 | async function setState() {}
23 | if (routeItem) {
24 | setAlbumInfo(routeItem);
25 | } else {
26 | const {data} = jellyfin.getSingleItem(session, routeItemId);
27 | data().then(data => setAlbumInfo(data));
28 | }
29 | setState();
30 | }, [routeItemId, jellyfin, routeItem, session]);
31 |
32 | useEffect(() => {
33 | function setBackGround() {
34 | if (albumInfo) {
35 | if (albumInfo?.ImageBlurHashes?.Primary !== undefined) {
36 | const averageColor = Blurhash.getAverageColor(
37 | albumInfo?.ImageBlurHashes?.Primary[
38 | Object.keys(albumInfo.ImageBlurHashes.Primary)[0]
39 | ],
40 | );
41 | setAverageColorRgb(
42 | `rgb(${averageColor?.r}, ${averageColor?.g}, ${averageColor?.b} )`,
43 | );
44 | } else {
45 | setAverageColorRgb('rgb(168, 44, 69)');
46 | }
47 | }
48 | }
49 | setBackGround();
50 | }, [albumInfo]);
51 |
52 | return {
53 | albumInfo,
54 | isDownloaded,
55 | averageColorRgb,
56 | albumItems,
57 | similarAlbums,
58 | };
59 | };
60 |
61 | export {useGetAlbum};
62 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Developer Documentation
2 |
3 | This page contains some commonly used commands, reasoning behind some of the engineering desicions, aswell as some guidlines for developing.
4 |
5 | ## Enviroment Setup
6 |
7 | For setup of the development enviroment setup consult the [React Native documentation](https://reactnative.dev/).
8 |
9 | ## Version Control
10 |
11 | Always create [atomic commits](https://en.wikipedia.org/wiki/Atomic_commit) with explicit commit messages. Ideally commits should also be signed.
12 | Example: `git commit -S`
13 |
14 | ## CI/CD
15 |
16 | ### Creating a release on Github
17 |
18 | Github Actions are configured to create a release whenever a commit is tagged with `v*`. In order to create a new release, update the version number in `package.json`, `app/constants.ts`, `android/app/build.gradle` and create fastlane changelog, where the file name - `$VERSION.txt` - matches the android release number. Use the exact same version number for the git tag.
19 |
20 | Example: `git tag -s "v1.0"`.
21 |
22 | ## Building and Bundling
23 |
24 | ### Building an production APK
25 |
26 | 1. Provide a Java Keystore in the `android` directory and add the neccessary information to `android/gradle.properties` like this:
27 |
28 | ```bash:android/gradle.properties
29 | BOUM_UPLOAD_STORE_FILE=example.jks
30 | BOUM_UPLOAD_STORE_PASSWORD=example
31 | BOUM_UPLOAD_KEY_ALIAS=example
32 | BOUM_UPLOAD_KEY_PASSWORD=example
33 | ```
34 |
35 | 2. Run `yarn android-build-release`
36 |
37 | ### Generating an APK from an AAB
38 |
39 | 1. Follow the instruction for poviding a `JKS`.
40 |
41 | 2. Run `yarn android-bundle-release`
42 |
43 | 3. Download the bundletool from [Google's Github](https://github.com/google/bundletool/releases)
44 |
45 | 4. Run the following command to generate a universal APK
46 | `java -jar bundletool-all-1.11.2.jar build-apks --mode=universal --bundle boum/android/app/build/outputs/bundle/release/app-release.aab --output universal.apks`
47 |
--------------------------------------------------------------------------------
/app/hooks/useGetArtist.ts:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from 'react';
2 | import {Blurhash} from 'react-native-blurhash';
3 |
4 | import {useStore} from '@boum/hooks';
5 | import {Session} from '@boum/types';
6 |
7 | const useGetArtist = (
8 | routeItem: object,
9 | routeItemId: string,
10 | session: Session,
11 | ) => {
12 | const jellyfin = useStore.getState().jellyfinClient;
13 |
14 | const [artistInfo, setArtistInfo] = useState(false);
15 | const [averageColorRgb, setAverageColorRgb] = useState('');
16 |
17 | const {artistItems} = jellyfin.getArtistItems(session, routeItemId);
18 | const {appearsOnItems} = jellyfin.getAppearsOn(session, routeItemId);
19 | const {similarArtists} = jellyfin.getSimilarArtists(session, routeItemId);
20 |
21 | useEffect(() => {
22 | async function setState() {}
23 | if (routeItem !== undefined) {
24 | setArtistInfo(routeItem);
25 | } else {
26 | const {data} = jellyfin.getSingleItem(session, routeItemId);
27 | data().then(data => setArtistInfo(data));
28 | }
29 | setState();
30 | }, [routeItemId, jellyfin, routeItem, session]);
31 |
32 | useEffect(() => {
33 | function setBackGround() {
34 | if (artistInfo) {
35 | if (artistInfo.ImageBlurHashes.Primary !== undefined) {
36 | const averageColor = Blurhash.getAverageColor(
37 | artistInfo.ImageBlurHashes.Primary[
38 | Object.keys(artistInfo.ImageBlurHashes.Primary)[0]
39 | ],
40 | );
41 | setAverageColorRgb(
42 | `rgb(${averageColor?.r}, ${averageColor?.g}, ${averageColor?.b} )`,
43 | );
44 | } else {
45 | setAverageColorRgb('rgb(168, 44, 69)');
46 | }
47 | }
48 | }
49 | setBackGround();
50 | }, [artistInfo]);
51 |
52 | return {
53 | artistInfo,
54 | averageColorRgb,
55 | artistItems,
56 | similarArtists,
57 | appearsOnItems,
58 | };
59 | };
60 |
61 | export {useGetArtist};
62 |
--------------------------------------------------------------------------------
/app/hooks/useGetDownloadItems.ts:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from 'react';
2 |
3 | import {DbService} from '@boum/lib/db/Service';
4 | import {useStore} from '@boum/hooks';
5 | import {TableName} from '@boum/types';
6 |
7 | const getDownloadItems = async (dbService: DbService) => {
8 | return new Promise(async function (resolve, reject) {
9 | const db = await dbService.getDBConnection();
10 |
11 | const items = await dbService
12 | .readParentEntries(db, TableName.ParentItems)
13 | .catch(err => reject(`Couldn't get data from DB: ${err}`));
14 |
15 | let downloadItems: Array = [];
16 |
17 | items.forEach(async (item, index) => {
18 | const children = await dbService.getChildrenEntriesForParent(db, item.id);
19 |
20 | downloadItems.push({
21 | id: item.id,
22 | name: item.name,
23 | metadata: item.metadata,
24 | children: children,
25 | });
26 | if (items.length === index + 1) {
27 | resolve(downloadItems);
28 | }
29 | });
30 | });
31 | };
32 |
33 | const useGetDownloadItems = (refresh?: boolean) => {
34 | const [downloadItems, setDownloadItems] = useState>(
35 | false,
36 | );
37 | const [gotDownloadItems, setGotDownloadItems] = useState(false);
38 | const [time, setTime] = useState(Date.now());
39 | const dbService = useStore.getState().dbService;
40 |
41 | useEffect(() => {
42 | if (refresh) {
43 | const interval = setInterval(() => setTime(Date.now()), 1000);
44 | return () => {
45 | clearInterval(interval);
46 | };
47 | }
48 | }, []);
49 |
50 | useEffect(() => {
51 | async function getItems() {
52 | getDownloadItems(dbService).then(res => {
53 | setDownloadItems(res);
54 | });
55 | }
56 | getItems().then(() => setGotDownloadItems(true));
57 | }, [time]);
58 |
59 | return {downloadItems, gotDownloadItems};
60 | };
61 |
62 | export {useGetDownloadItems};
63 |
--------------------------------------------------------------------------------
/app/components/Library/SongsListItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {MediaItem, Session} from '@boum/types';
3 | import {styles} from '@boum/components/Search';
4 | import {playTrack} from '@boum/lib/audio';
5 | import FastImage from 'react-native-fast-image';
6 | import {TouchableOpacity, Text} from 'react-native';
7 | import {RemoteMediaClient} from 'react-native-google-cast';
8 | import {CastService} from '@boum/lib/cast';
9 |
10 | type SongListItemProps = {
11 | item: MediaItem;
12 | session: Session;
13 | bitrateLimit: number;
14 | castService: CastService;
15 | castClient: RemoteMediaClient;
16 | };
17 |
18 | class SongListItem extends React.PureComponent {
19 | render() {
20 | return (
21 | {
24 | if (this.props.castClient !== null) {
25 | this.props.castService.playTrack(
26 | this.props.session,
27 | this.props.item,
28 | 0,
29 | this.props.castClient,
30 | );
31 | } else {
32 | await playTrack(
33 | this.props.item,
34 | this.props.session,
35 | this.props.bitrateLimit,
36 | );
37 | }
38 | }}
39 | style={styles.resultContainer}>
40 | <>
41 | {this.props.item.Id != null ? (
42 |
51 | ) : null}
52 | {this.props.item.Name}
53 | >
54 |
55 | );
56 | }
57 | }
58 |
59 | export {SongListItem};
60 |
--------------------------------------------------------------------------------
/android/app/src/main/java/de/eindm/boum/newarchitecture/modules/MainApplicationTurboModuleManagerDelegate.java:
--------------------------------------------------------------------------------
1 | package de.eindm.boum.newarchitecture.modules;
2 |
3 | import com.facebook.jni.HybridData;
4 | import com.facebook.react.ReactPackage;
5 | import com.facebook.react.ReactPackageTurboModuleManagerDelegate;
6 | import com.facebook.react.bridge.ReactApplicationContext;
7 | import com.facebook.soloader.SoLoader;
8 | import java.util.List;
9 |
10 | /**
11 | * Class responsible to load the TurboModules. This class has native methods and needs a
12 | * corresponding C++ implementation/header file to work correctly (already placed inside the jni/
13 | * folder for you).
14 | *
15 | * Please note that this class is used ONLY if you opt-in for the New Architecture (see the
16 | * `newArchEnabled` property). Is ignored otherwise.
17 | */
18 | public class MainApplicationTurboModuleManagerDelegate
19 | extends ReactPackageTurboModuleManagerDelegate {
20 |
21 | private static volatile boolean sIsSoLibraryLoaded;
22 |
23 | protected MainApplicationTurboModuleManagerDelegate(
24 | ReactApplicationContext reactApplicationContext, List packages) {
25 | super(reactApplicationContext, packages);
26 | }
27 |
28 | protected native HybridData initHybrid();
29 |
30 | native boolean canCreateTurboModule(String moduleName);
31 |
32 | public static class Builder extends ReactPackageTurboModuleManagerDelegate.Builder {
33 | protected MainApplicationTurboModuleManagerDelegate build(
34 | ReactApplicationContext context, List packages) {
35 | return new MainApplicationTurboModuleManagerDelegate(context, packages);
36 | }
37 | }
38 |
39 | @Override
40 | protected synchronized void maybeLoadOtherSoLibraries() {
41 | if (!sIsSoLibraryLoaded) {
42 | // If you change the name of your application .so file in the Android.mk file,
43 | // make sure you update the name here as well.
44 | SoLoader.loadLibrary("boum_appmodules");
45 | sIsSoLibraryLoaded = true;
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/app/components/Player/SleeptimePicker.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {StyleSheet, View} from 'react-native';
3 |
4 | import {colours} from '@boum/constants';
5 | import {useStore} from '@boum/hooks';
6 | import {Picker} from '@react-native-picker/picker';
7 |
8 | const SleeptimePicker = () => {
9 | const setSleepTimerState = useStore(state => state.setSleepTimer);
10 |
11 | const setSleeptimer = (time: number) => {
12 | const timeNow = Date.now();
13 | if (time) {
14 | setSleepTimerState(timeNow + time);
15 | } else {
16 | setSleepTimerState(0);
17 | }
18 | };
19 |
20 | return (
21 |
22 | setSleeptimer(itemValue)}
24 | enabled={true}
25 | itemStyle={styles.picker}>
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | const styles = StyleSheet.create({
41 | container: {
42 | justifyContent: 'center',
43 | marginHorizontal: 20,
44 | backgroundColor: 'white',
45 | },
46 | text: {
47 | color: colours.white,
48 | fontSize: 16,
49 | textAlign: 'center',
50 | fontFamily: 'Inter-Regular',
51 | },
52 | picker: {
53 | color: colours.white,
54 | width: '100%',
55 | },
56 | item: {
57 | color: colours.black,
58 | backgroundColor: colours.white,
59 | },
60 | });
61 |
62 | export default SleeptimePicker;
63 |
--------------------------------------------------------------------------------
/app/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Platform, StatusBar, StyleSheet, UIManager} from 'react-native';
3 | import {GestureHandlerRootView} from 'react-native-gesture-handler';
4 | import {MenuProvider} from 'react-native-popup-menu';
5 | import {SafeAreaProvider} from 'react-native-safe-area-context';
6 |
7 | import {LoadingSpinner} from '@boum/components/Generic';
8 | import {colours} from '@boum/constants';
9 | import {
10 | useInitializeCastClient,
11 | useInitializeDb,
12 | useIntializeSession,
13 | useSetupPlayer,
14 | useStore,
15 | useTrackPlayer,
16 | } from '@boum/hooks';
17 | import {LoginScreen} from '@boum/screens';
18 | import RootStack from '@boum/stacks/RootStack';
19 | import {NavigationContainer} from '@react-navigation/native';
20 |
21 | if (Platform.OS === 'android') {
22 | UIManager.setLayoutAnimationEnabledExperimental &&
23 | UIManager.setLayoutAnimationEnabledExperimental(true);
24 | }
25 |
26 | const App = () => {
27 | useSetupPlayer();
28 | useTrackPlayer();
29 | useIntializeSession();
30 | useInitializeDb();
31 | useInitializeCastClient();
32 |
33 | const playerIsSetup = useStore(state => state.playerIsSetup);
34 | const gotLoginStatus = useStore(state => state.gotLoginStatus);
35 | const session = useStore(state => state.session);
36 |
37 | return (
38 |
39 |
40 |
41 |
42 | {session?.userId && playerIsSetup ? (
43 |
44 |
45 |
46 | ) : gotLoginStatus && playerIsSetup ? (
47 |
48 | ) : (
49 |
50 | )}
51 |
52 |
53 |
54 | );
55 | };
56 |
57 | const styles = StyleSheet.create({
58 | root: {flex: 1, backgroundColor: colours.black},
59 | });
60 |
61 | export default App;
62 |
--------------------------------------------------------------------------------
/android/build.gradle:
--------------------------------------------------------------------------------
1 | import org.apache.tools.ant.taskdefs.condition.Os
2 |
3 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
4 |
5 | buildscript {
6 | ext {
7 | buildToolsVersion = "33.0.0"
8 | minSdkVersion = 21
9 | compileSdkVersion = 33
10 | targetSdkVersion = 33
11 | castFrameworkVersion = "21.0.0" // Google Cast
12 |
13 | if (System.properties['os.arch'] == "aarch64") {
14 | // For M1 Users we need to use the NDK 24 which added support for aarch64
15 | ndkVersion = "24.0.8215888"
16 | } else {
17 | // Otherwise we default to the side-by-side NDK version from AGP.
18 | ndkVersion = "21.4.7075529"
19 | }
20 | }
21 | repositories {
22 | google()
23 | mavenCentral()
24 | }
25 | dependencies {
26 | classpath("com.android.tools.build:gradle:7.0.4")
27 | classpath("com.facebook.react:react-native-gradle-plugin")
28 | classpath("de.undercouch:gradle-download-task:4.1.2")
29 | // NOTE: Do not place your application dependencies here; they belong
30 | // in the individual module build.gradle files
31 | }
32 | }
33 |
34 | allprojects {
35 | repositories {
36 | maven {
37 | // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
38 | url("$rootDir/../node_modules/react-native/android")
39 | }
40 | maven {
41 | // Android JSC is installed from npm
42 | url("$rootDir/../node_modules/jsc-android/dist")
43 | }
44 | mavenCentral {
45 | // We don't want to fetch react-native from Maven Central as there are
46 | // older versions over there.
47 | content {
48 | excludeGroup "com.facebook.react"
49 | }
50 | }
51 | google()
52 | maven { url 'https://www.jitpack.io' }
53 | jcenter() {
54 | content {
55 | includeGroup("com.google.android.exoplayer")
56 | }
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/app/stacks/LibraryStack.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import NowPlayingBar from '@boum/components/Player/NowPlayingBar';
4 | import {
5 | AlbumScreen,
6 | AlbumsScreen,
7 | ArtistScreen,
8 | ArtistsScreen,
9 | BooksScreen,
10 | GenreScreen,
11 | GenresScreen,
12 | LibraryScreen,
13 | MovieScreen,
14 | MoviesScreen,
15 | PlaylistScreen,
16 | PlaylistsScreen,
17 | TracksScreen,
18 | } from '@boum/screens';
19 | import {NavigationProp} from '@react-navigation/native';
20 | import {createNativeStackNavigator} from '@react-navigation/native-stack';
21 | import {FolderScreen} from '@boum/screens/FolderScreen';
22 |
23 | type LibraryStackProps = {
24 | navigation: NavigationProp;
25 | };
26 |
27 | const LibraryStack: React.FC = ({navigation}) => {
28 | const LibraryStack = createNativeStackNavigator();
29 |
30 | return (
31 | <>
32 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | >
51 | );
52 | };
53 |
54 | export default LibraryStack;
55 |
--------------------------------------------------------------------------------
/app/lib/notifications/Service.ts:
--------------------------------------------------------------------------------
1 | import {colours} from '@boum/constants';
2 | import {MediaItem} from '@boum/types';
3 | import notifee, {AndroidImportance} from '@notifee/react-native';
4 |
5 | class NotificationService {
6 | public createDownloadNotification = async (
7 | list: MediaItem,
8 | ): Promise => {
9 | // Request permissions (required for iOS)
10 | await notifee.requestPermission();
11 |
12 | // Create a channel (required for Android)
13 | const channelId = await notifee.createChannel({
14 | id: 'boum-downloads',
15 | name: 'Downloads',
16 | lights: false,
17 | vibration: true,
18 | sound: 'default',
19 | importance: AndroidImportance.LOW,
20 | });
21 |
22 | return channelId;
23 | };
24 |
25 | public updateDownloadNotification = async (
26 | channelId: string,
27 | list: MediaItem,
28 | totalItems: number,
29 | downloadedItems: number,
30 | ) => {
31 | // Display a notification
32 | if (downloadedItems < totalItems) {
33 | await notifee.displayNotification({
34 | id: list.Id + '-downloading',
35 | title: 'Downloading ' + list.Name,
36 | body: `Downloading ${totalItems} items`,
37 | android: {
38 | channelId: channelId,
39 | color: colours.accent,
40 | onlyAlertOnce: true,
41 | pressAction: {
42 | id: 'default',
43 | },
44 | progress: {
45 | max: totalItems,
46 | current: downloadedItems,
47 | },
48 | },
49 | });
50 | } else if (downloadedItems === totalItems) {
51 | await notifee.cancelNotification(list.Id + '-downloading');
52 | await notifee.displayNotification({
53 | id: list.Id + '-finished',
54 | title: 'Finished Downloading ' + list.Name,
55 | body: `Downloaded ${totalItems} items`,
56 | android: {
57 | channelId: channelId,
58 | color: colours.accent,
59 | onlyAlertOnce: true,
60 | pressAction: {
61 | id: 'default',
62 | },
63 | },
64 | });
65 | }
66 | };
67 | }
68 |
69 | export {NotificationService};
70 |
--------------------------------------------------------------------------------
/app/components/Settings/CustomListItem.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {StyleSheet, Text, TouchableOpacity, View} from 'react-native';
3 |
4 | import {colours} from '@boum/constants';
5 |
6 | import Icon from 'react-native-vector-icons/Ionicons';
7 | import {CustomHomeListItem, SuccessMessage} from '@boum/types';
8 | import {useStore} from '@boum/hooks';
9 |
10 | type CustomListItemProps = {
11 | list: CustomHomeListItem;
12 | };
13 |
14 | const CustomListItem = ({list}: CustomListItemProps) => {
15 | const [successMessage, setSuccessMessage] =
16 | useState('not triggered');
17 |
18 | const triggerRefreshHomeScreen = useStore(
19 | state => state.setRefreshHomeScreen,
20 | );
21 |
22 | const dbService = useStore.getState().dbService;
23 |
24 | return (
25 |
26 | {list.title}
27 |
29 | await dbService.deleteCustomList(list.title).then(res => {
30 | setSuccessMessage(res);
31 | triggerRefreshHomeScreen();
32 | })
33 | }>
34 | {successMessage === 'not triggered' ? (
35 |
36 | ) : successMessage === 'success' ? (
37 |
38 | ) : (
39 |
40 | )}
41 |
42 |
43 | );
44 | };
45 |
46 | const styles = StyleSheet.create({
47 | container: {
48 | flex: 1,
49 | flexDirection: 'row',
50 | },
51 | listContainer: {
52 | alignItems: 'flex-start',
53 | flex: 1,
54 | flexWrap: 'wrap',
55 | paddingHorizontal: 15,
56 | justifyContent: 'flex-start',
57 | },
58 | text: {
59 | color: colours.white,
60 | fontSize: 18,
61 | width: '90%',
62 | marginVertical: 12,
63 | fontFamily: 'Inter-Medium',
64 | },
65 | picker: {
66 | width: '70%',
67 | color: 'white',
68 | backgroundColor: colours.grey['700'],
69 | },
70 | });
71 |
72 | export {CustomListItem};
73 |
--------------------------------------------------------------------------------
/app/screens/ListManagerScreen.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {ScrollView, StyleSheet, Text, View} from 'react-native';
3 |
4 | import {colours} from '@boum/constants';
5 | import {useStore} from '@boum/hooks';
6 | import {CustomListCreator} from '@boum/components/Settings';
7 | import {useGetCustomLists} from '@boum/hooks/useGetCustomLists';
8 | import {CustomListItem} from '@boum/components/Settings/CustomListItem';
9 |
10 | const ListManagerScreen: React.FC = () => {
11 | const session = useStore(state => state.session);
12 | const refreshHomeScreen = useStore(state => state.refreshHomeScreen);
13 | const customLists = useStore(state => state.customLists);
14 |
15 | useGetCustomLists(refreshHomeScreen);
16 | return (
17 |
18 | Custom Lists
19 | {customLists && customLists.length >= 1 ? (
20 | <>
21 | Your current lists:
22 |
23 | {customLists
24 | ? customLists.map(list => (
25 |
26 | ))
27 | : null}
28 |
29 | >
30 | ) : null}
31 |
32 |
33 | );
34 | };
35 |
36 | const styles = StyleSheet.create({
37 | container: {
38 | backgroundColor: colours.black,
39 | },
40 | header: {
41 | color: colours.white,
42 | paddingTop: 30,
43 | fontSize: 32,
44 | fontFamily: 'Inter-ExtraBold',
45 | justifyContent: 'flex-start',
46 | textAlign: 'center',
47 | },
48 | listContainer: {
49 | alignItems: 'flex-start',
50 | flex: 1,
51 | flexWrap: 'wrap',
52 | paddingHorizontal: 15,
53 | justifyContent: 'flex-start',
54 | },
55 | text: {
56 | color: colours.white,
57 | fontSize: 20,
58 | paddingVertical: 16,
59 | paddingHorizontal: 15,
60 | fontFamily: 'Inter-Medium',
61 | },
62 | picker: {
63 | width: '70%',
64 | color: 'white',
65 | backgroundColor: colours.grey['700'],
66 | },
67 | });
68 |
69 | export {ListManagerScreen};
70 |
--------------------------------------------------------------------------------
/app/components/Search/RowSongs.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Text, TouchableOpacity, View} from 'react-native';
3 | import FastImage from 'react-native-fast-image';
4 |
5 | import {playTrack} from '@boum/lib/audio';
6 |
7 | import {styles} from '@boum/components/Search';
8 | import {useBitrateLimit, useStore} from '@boum/hooks';
9 | import {LibraryItemList, MediaItem, Session} from '@boum/types';
10 |
11 | type RowSongsProps = {
12 | songs: LibraryItemList;
13 | session: Session;
14 | };
15 |
16 | const RowSongs: React.FC = ({songs, session}) => {
17 | const bitrate = useBitrateLimit();
18 | const castClient = useStore(state => state.castClient);
19 | const castService = useStore(state => state.castService);
20 |
21 | const playSong = async (item: MediaItem) => {
22 | if (castClient !== null) {
23 | castService.playTrack(session, item, 0, castClient);
24 | } else {
25 | await playTrack(item, session, bitrate);
26 | }
27 | };
28 | return (
29 | <>
30 |
31 | {songs !== undefined && songs.TotalRecordCount >= 1 ? (
32 | <>
33 | Songs
34 | {songs.Items.map(item => (
35 | playSong(item)}
38 | style={styles.resultContainer}>
39 | <>
40 | {item.Id != null ? (
41 |
50 | ) : null}
51 | {item.Name}
52 | >
53 |
54 | ))}
55 | >
56 | ) : null}
57 |
58 | >
59 | );
60 | };
61 |
62 | export {RowSongs};
63 |
--------------------------------------------------------------------------------
/app/screens/DownloadsScreen.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Dimensions, ScrollView, StyleSheet, Text} from 'react-native';
3 |
4 | import {LoadingSpinner} from '@boum/components/Generic';
5 | import DownloadItem from '@boum/components/Settings/DownloadItem';
6 | import {colours, sizes} from '@boum/constants';
7 | import {useGetDownloadItems, useStore} from '@boum/hooks';
8 | import {ButtonBoum} from '@boum/components/Settings';
9 |
10 | const width = Dimensions.get('window').width;
11 |
12 | const DownloadsScreen: React.FC = () => {
13 | const {downloadItems, gotDownloadItems} = useGetDownloadItems(true);
14 |
15 | const storageService = useStore.getState().storageService;
16 | const dbService = useStore.getState().dbService;
17 | const session = useStore.getState().session;
18 |
19 | // TODO: Make this look nicer
20 | return (
21 |
22 | Downloads
23 | {
25 | await storageService.redownloadItems(session);
26 | }}
27 | title={'Re-trigger downloads'}
28 | />
29 | {downloadItems !== undefined && downloadItems ? (
30 | <>
31 | {downloadItems.map(item => (
32 |
33 | ))}
34 | >
35 | ) : gotDownloadItems ? (
36 | You have no downloads
37 | ) : (
38 |
39 | )}
40 |
41 | );
42 | };
43 |
44 | const styles = StyleSheet.create({
45 | container: {
46 | width: '100%',
47 | flexDirection: 'column',
48 | flex: 1,
49 | backgroundColor: colours.black,
50 | padding: sizes.marginListX,
51 | },
52 | title: {
53 | color: 'white',
54 | fontSize: 42,
55 | fontFamily: 'InterBold',
56 | paddingBottom: sizes.marginListX,
57 | marginTop: width * 0.05,
58 | marginLeft: width * 0.02,
59 | },
60 | text: {
61 | color: colours.grey['300'],
62 | fontSize: 20,
63 | fontFamily: 'InterBold',
64 | paddingBottom: sizes.marginListX,
65 | alignSelf: 'center',
66 | marginTop: '10%',
67 | },
68 | });
69 |
70 | export {DownloadsScreen};
71 |
--------------------------------------------------------------------------------
/ios/boumTests/boumTests.m:
--------------------------------------------------------------------------------
1 | #import
2 | #import
3 |
4 | #import
5 | #import
6 |
7 | #define TIMEOUT_SECONDS 600
8 | #define TEXT_TO_LOOK_FOR @"Welcome to React"
9 |
10 | @interface boumTests : XCTestCase
11 |
12 | @end
13 |
14 | @implementation boumTests
15 |
16 | - (BOOL)findSubviewInView:(UIView *)view matching:(BOOL (^)(UIView *view))test
17 | {
18 | if (test(view)) {
19 | return YES;
20 | }
21 | for (UIView *subview in [view subviews]) {
22 | if ([self findSubviewInView:subview matching:test]) {
23 | return YES;
24 | }
25 | }
26 | return NO;
27 | }
28 |
29 | - (void)testRendersWelcomeScreen
30 | {
31 | UIViewController *vc = [[[RCTSharedApplication() delegate] window] rootViewController];
32 | NSDate *date = [NSDate dateWithTimeIntervalSinceNow:TIMEOUT_SECONDS];
33 | BOOL foundElement = NO;
34 |
35 | __block NSString *redboxError = nil;
36 | #ifdef DEBUG
37 | RCTSetLogFunction(
38 | ^(RCTLogLevel level, RCTLogSource source, NSString *fileName, NSNumber *lineNumber, NSString *message) {
39 | if (level >= RCTLogLevelError) {
40 | redboxError = message;
41 | }
42 | });
43 | #endif
44 |
45 | while ([date timeIntervalSinceNow] > 0 && !foundElement && !redboxError) {
46 | [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
47 | [[NSRunLoop mainRunLoop] runMode:NSRunLoopCommonModes beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
48 |
49 | foundElement = [self findSubviewInView:vc.view
50 | matching:^BOOL(UIView *view) {
51 | if ([view.accessibilityLabel isEqualToString:TEXT_TO_LOOK_FOR]) {
52 | return YES;
53 | }
54 | return NO;
55 | }];
56 | }
57 |
58 | #ifdef DEBUG
59 | RCTSetLogFunction(RCTDefaultLogFunction);
60 | #endif
61 |
62 | XCTAssertNil(redboxError, @"RedBox error: %@", redboxError);
63 | XCTAssertTrue(foundElement, @"Couldn't find element with text '%@' in %d seconds", TEXT_TO_LOOK_FOR, TIMEOUT_SECONDS);
64 | }
65 |
66 | @end
67 |
--------------------------------------------------------------------------------
/app/screens/SearchScreen.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {ScrollView, StyleSheet, View} from 'react-native';
3 |
4 | import {RowNavigation, RowSongs, SearchBar} from '@boum/components/Search';
5 | import {colours} from '@boum/constants';
6 | import {useCancelableSearch, useSearchStore, useStore} from '@boum/hooks';
7 | import {NavigationProp} from '@react-navigation/native';
8 |
9 | type SearchScreenProps = {
10 | navigation: NavigationProp;
11 | };
12 |
13 | const SearchScreen: React.FC = ({navigation}) => {
14 | const session = useStore(state => state.session);
15 |
16 | const searchInput = useSearchStore(state => state.searchInput);
17 | const res = useCancelableSearch(session, searchInput);
18 |
19 | return (
20 |
21 |
22 |
23 | {searchInput.length >= 1 &&
24 | res !== undefined &&
25 | res.albums !== undefined ? (
26 | <>
27 |
28 |
34 |
40 | >
41 | ) : null}
42 |
43 |
44 | );
45 | };
46 |
47 | const styles = StyleSheet.create({
48 | container: {
49 | flex: 1,
50 | backgroundColor: colours.black,
51 | color: colours.white,
52 | paddingTop: 35,
53 | },
54 | viewContainer: {
55 | flex: 1,
56 | alignItems: 'center',
57 | justifyContent: 'center',
58 | width: '95%',
59 | },
60 | result: {
61 | backgroundColor: colours.accent,
62 | color: colours.white,
63 | },
64 | input: {
65 | height: 40,
66 | margin: 12,
67 | borderWidth: 1,
68 | padding: 10,
69 | width: '100%',
70 | color: colours.black,
71 | backgroundColor: colours.white,
72 | borderRadius: 5,
73 | },
74 | });
75 |
76 | export {SearchScreen};
77 |
--------------------------------------------------------------------------------
/app/components/Library/LibraryListItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {StyleSheet, Text, TouchableOpacity, View} from 'react-native';
3 | import FastImage from 'react-native-fast-image';
4 |
5 | import {colours} from '@boum/constants';
6 | import {MediaItem, Session} from '@boum/types';
7 | import {NavigationProp} from '@react-navigation/native';
8 |
9 | type LibraryListItemProps = {
10 | navigationDestination: string;
11 | item: MediaItem;
12 | navigation: NavigationProp;
13 | session: Session;
14 | };
15 |
16 | class LibraryListItem extends React.PureComponent {
17 | render() {
18 | return (
19 | {
21 | // FIXME: Extract this from the arrow function
22 | this.props.navigation.push(this.props.navigationDestination, {
23 | itemId: this.props.item.Id,
24 | name: this.props.item.Name,
25 | item: this.props.item,
26 | });
27 | }}>
28 |
29 |
38 |
39 | {this.props.item.Name}
40 |
41 |
42 |
43 | );
44 | }
45 | }
46 |
47 | const styles = StyleSheet.create({
48 | container: {
49 | flex: 1,
50 | flexDirection: 'row',
51 | height: 60,
52 | width: '100%',
53 | paddingTop: 10,
54 | paddingBottom: 10,
55 | paddingRight: 10,
56 | paddingLeft: 10,
57 | backgroundColor: colours.black,
58 | alignItems: 'center',
59 | },
60 | textContainer: {
61 | paddingRight: 13,
62 | },
63 | text: {
64 | flex: 1,
65 | color: colours.white,
66 | fontSize: 16,
67 | fontFamily: 'Inter-Medium',
68 | textAlign: 'left',
69 | paddingLeft: 14,
70 | },
71 | image: {
72 | width: 45,
73 | height: 45,
74 | },
75 | });
76 |
77 | export default LibraryListItem;
78 |
--------------------------------------------------------------------------------
/android/app/src/main/jni/MainComponentsRegistry.cpp:
--------------------------------------------------------------------------------
1 | #include "MainComponentsRegistry.h"
2 |
3 | #include
4 | #include
5 | #include
6 | #include
7 |
8 | namespace facebook {
9 | namespace react {
10 |
11 | MainComponentsRegistry::MainComponentsRegistry(ComponentFactory *delegate) {}
12 |
13 | std::shared_ptr
14 | MainComponentsRegistry::sharedProviderRegistry() {
15 | auto providerRegistry = CoreComponentsRegistry::sharedProviderRegistry();
16 |
17 | // Custom Fabric Components go here. You can register custom
18 | // components coming from your App or from 3rd party libraries here.
19 | //
20 | // providerRegistry->add(concreteComponentDescriptorProvider<
21 | // AocViewerComponentDescriptor>());
22 | return providerRegistry;
23 | }
24 |
25 | jni::local_ref
26 | MainComponentsRegistry::initHybrid(
27 | jni::alias_ref,
28 | ComponentFactory *delegate) {
29 | auto instance = makeCxxInstance(delegate);
30 |
31 | auto buildRegistryFunction =
32 | [](EventDispatcher::Weak const &eventDispatcher,
33 | ContextContainer::Shared const &contextContainer)
34 | -> ComponentDescriptorRegistry::Shared {
35 | auto registry = MainComponentsRegistry::sharedProviderRegistry()
36 | ->createComponentDescriptorRegistry(
37 | {eventDispatcher, contextContainer});
38 |
39 | auto mutableRegistry =
40 | std::const_pointer_cast(registry);
41 |
42 | mutableRegistry->setFallbackComponentDescriptor(
43 | std::make_shared(
44 | ComponentDescriptorParameters{
45 | eventDispatcher, contextContainer, nullptr}));
46 |
47 | return registry;
48 | };
49 |
50 | delegate->buildRegistryFunction = buildRegistryFunction;
51 | return instance;
52 | }
53 |
54 | void MainComponentsRegistry::registerNatives() {
55 | registerHybrid({
56 | makeNativeMethod("initHybrid", MainComponentsRegistry::initHybrid),
57 | });
58 | }
59 |
60 | } // namespace react
61 | } // namespace facebook
62 |
--------------------------------------------------------------------------------
/app/components/SingleItemHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Dimensions,
4 | StyleSheet,
5 | Text,
6 | TouchableOpacity,
7 | View,
8 | } from 'react-native';
9 | import Icon from 'react-native-vector-icons/Ionicons';
10 |
11 | import {colours, sizes} from '@boum/constants';
12 | import {NavigationProp} from '@react-navigation/native';
13 | import {SlideInContextMenu} from '@boum/components/ContextMenu/ContextMenu';
14 | import {
15 | LibraryItemList,
16 | MediaItem,
17 | MediaType,
18 | ScreenMode,
19 | Session,
20 | } from '@boum/types';
21 |
22 | const width = Dimensions.get('window').width;
23 | const height = Dimensions.get('window').height;
24 |
25 | type Props = {
26 | navigation: NavigationProp;
27 | contextAction?: () => void;
28 | mediaItem: MediaItem;
29 | mediaType: MediaType;
30 | session?: Session;
31 | screenMode: ScreenMode;
32 | listItems?: LibraryItemList;
33 | };
34 |
35 | class SingleItemHeader extends React.PureComponent {
36 | render() {
37 | return (
38 | <>
39 |
40 |
43 |
44 |
45 |
46 |
47 | {this.props.session ? (
48 |
55 | ) : null}
56 |
57 | >
58 | );
59 | }
60 | }
61 |
62 | const styles = StyleSheet.create({
63 | topButtonsContainer: {
64 | flexDirection: 'row',
65 | alignItems: 'center',
66 | justifyContent: 'space-between',
67 | height: height * 0.06,
68 | marginLeft: width * 0.02,
69 | marginRight: width * 0.02,
70 | marginTop: 30,
71 | },
72 |
73 | actionButton: {
74 | alignSelf: 'center',
75 | paddingLeft: sizes.marginListX / 2,
76 | paddingRight: sizes.marginListX / 2,
77 | },
78 | });
79 |
80 | export default SingleItemHeader;
81 |
--------------------------------------------------------------------------------
/app/lib/settings/scanLibrary.ts:
--------------------------------------------------------------------------------
1 | import {versionBoum} from '@boum/constants';
2 | import {Session} from '@boum/types';
3 |
4 | const scanLibrary = async (session: Session) => {
5 | let result = '';
6 | let numberOfMusicLibraries = 0;
7 | const headers = `MediaBrowser Client="Boum",DeviceId="${session.deviceId}", Version="${versionBoum}", Token=${session.accessToken}`;
8 |
9 | // Check if user is admin
10 | const queryUser = `${session.hostname}/Users/${session.userId}`;
11 | await fetch(queryUser, {
12 | headers: {
13 | Accept: 'application/json',
14 | 'X-Emby-Authorization': headers,
15 | },
16 | }).then(async res => {
17 | const json = await res.json();
18 | // Get libraries of type ""
19 | if (json.Policy.IsAdministrator) {
20 | const queryLibraries = `${session.hostname}/Library/VirtualFolders`;
21 | await fetch(queryLibraries, {
22 | method: 'GET',
23 | headers: {
24 | Accept: 'application/json',
25 | 'X-Emby-Authorization': headers,
26 | },
27 | }).then(async res => {
28 | const json = await res.json();
29 | const musicLibraries = json.filter(
30 | library => library.CollectionType === 'music',
31 | );
32 | numberOfMusicLibraries = musicLibraries.length;
33 | musicLibraries.forEach(async library => {
34 | const queryRefreshLibrary = `${session.hostname}/Items/${library.ItemId}/Refresh?Recursive=true&ImageRefreshMode=Default&MetadataRefreshMode=Default&ReplaceAllImages=false&ReplaceAllMetadata=false`;
35 | await fetch(queryRefreshLibrary, {
36 | method: 'POST',
37 | headers: {
38 | Accept: 'application/json',
39 | 'X-Emby-Authorization': headers,
40 | },
41 | }).then(async res => {
42 | console.log('STATUS REFRESH: ', res.status);
43 | });
44 | });
45 | });
46 | if (numberOfMusicLibraries === 1) {
47 | result = `Started refreshing ${numberOfMusicLibraries} music library.`;
48 | } else {
49 | result = `Started refreshing ${numberOfMusicLibraries} music libraries.`;
50 | }
51 | } else {
52 | result = "You aren't an admin so no library was refreshed.";
53 | }
54 | });
55 |
56 | return result;
57 | };
58 |
59 | export {scanLibrary};
60 |
--------------------------------------------------------------------------------
/app/components/Video/VideoItemCard.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Dimensions,
4 | StyleSheet,
5 | Text,
6 | TouchableOpacity,
7 | View,
8 | } from 'react-native';
9 | import FastImage from 'react-native-fast-image';
10 |
11 | import {colours} from '@boum/constants';
12 | import {Session, VideoMediaItem, VideoPerson} from '@boum/types';
13 | import {NavigationProp} from '@react-navigation/native';
14 |
15 | const width = Dimensions.get('window').width;
16 |
17 | type VideoItemCardProps = {
18 | item: VideoMediaItem | VideoPerson;
19 | session: Session;
20 | navigation: NavigationProp;
21 | };
22 |
23 | const VideoItemCard = ({item, session, navigation}: VideoItemCardProps) => {
24 | return (
25 | {
27 | if (item?.MediaType === 'Video') {
28 | navigation.push('Movie', {
29 | itemId: item.Id,
30 | item: item,
31 | });
32 | }
33 | }}>
34 |
35 |
49 |
50 | {item.Name}
51 |
52 | {item.Role !== undefined ? (
53 |
54 | {item.Role}
55 |
56 | ) : null}
57 |
58 |
59 | );
60 | };
61 |
62 | const styles = StyleSheet.create({
63 | container: {
64 | flex: 1,
65 | paddingLeft: width * 0.05,
66 | width: width * 0.45,
67 | maxWidth: width * 0.45,
68 | },
69 | name: {
70 | fontSize: 18,
71 | fontFamily: 'Inter-Medium',
72 | color: colours.white,
73 | flexWrap: 'wrap',
74 | },
75 | role: {
76 | fontSize: 12,
77 | fontFamily: 'Inter-Regular',
78 | color: colours.white,
79 | flexWrap: 'wrap',
80 | },
81 | image: {
82 | width: 300,
83 | height: 300,
84 | },
85 | });
86 |
87 | export {VideoItemCard};
88 |
--------------------------------------------------------------------------------
/DOCUMENTATION.md:
--------------------------------------------------------------------------------
1 | # Documentation
2 |
3 | ## Download Speed
4 |
5 | Depending on your network direct downloads can be actually be signigicantly faster than download at a selected bitrate, since Jellyfin needs to transcode these using FFMPEG.
6 |
7 | ## Custom Lists
8 |
9 | Custom Lists allow have shortcuts to predefined queries of your music library.
10 |
11 | Examples:
12 |
13 | - Genre: Jazz, SortBy: Random
14 | - Genre: Classical, SortBy: Time added to library, Search Query: Bach
15 | - Genre: Electronic, Filter: Favorites
16 |
17 | 
18 |
19 | ## Chromecast
20 |
21 | When using boum outside of your home network you should always connect to Jellyfin through a VPN connecting you to your home network, so that you're never exposing your Jellyfin server directly to this internet.
22 | 
23 |
24 | This means however that when trying to play music via Chromecast outside your home network, Chromecast cannot stream the music, since it can't access your Jellyfin server through the VPN. Luckily boum provides a solution to this. To illustrate this feature, let's take the following example:
25 |
26 | Your Jellyfin servers adress is: `http://192.168.1.2:8096`. When you're in your home network you connect via it's local adress. When you're on the go, you connect back to your home network via Wireguard which means you can still access your Jellyfin server via it's local adress. You also expose Jellyfin through a reverse proxy on `https://jellyfin.example.com` with some kind of authentication proxy in front, let's say [Authelia](https://www.authelia.com/), so that friends can use your server without them needing access to your home network, all while not exposing your Jellyfin server to the Internet. Since Chromecast devices outside of your home network cannot access Jellyfin through the VPN, we need to add an excpetion to only to API endpoint(s) for streaming audio and - if wanted - covers to the authentication proxy, so that all APIs for managing Jellyfin are still locked down.
27 |
28 | For Authelia you'd need to add this to your configuration:
29 |
30 | ```yaml:configuration.yml
31 | access_control:
32 | default_policy: deny
33 | rules:
34 | - domain: "jellyfin.example.org"
35 | resources:
36 | - '^/audio([/?].*)?$'
37 | policy: bypass
38 | - domain: "*.example.org"
39 | policy: one_factor
40 | ```
41 |
--------------------------------------------------------------------------------
/app/screens/MovieScreen.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useState} from 'react';
2 | import {StyleSheet, ScrollView} from 'react-native';
3 | import {Blurhash} from 'react-native-blurhash';
4 | import {NavigationProp, RouteProp} from '@react-navigation/native';
5 |
6 | import {LoadingSpinner} from '@boum/components/Generic';
7 | import {VideoHeader} from '@boum/components/Video';
8 | import {useStore} from '@boum/hooks';
9 | import {colours} from '@boum/constants';
10 | import {MediaItem} from '@boum/types';
11 |
12 | type MovieScreenProps = {
13 | navigation: NavigationProp;
14 | route: RouteProp<{params: {item: MediaItem}}>;
15 | };
16 |
17 | const MovieScreen: React.FC = ({navigation, route}) => {
18 | const jellyfin = useStore.getState().jellyfinClient;
19 | const {item} = route.params;
20 |
21 | const session = useStore(state => state.session);
22 |
23 | const [averageColorRgb, setAverageColorRgb] =
24 | useState('rgb(168, 44, 69)');
25 |
26 | const {data, error} = jellyfin.getSingleItemSwr(session, item.Id);
27 | const {similarAlbums} = jellyfin.getSimilarItems(session, item.Id);
28 |
29 | useEffect(() => {
30 | function setBackGround() {
31 | if (data) {
32 | if (data.ImageBlurHashes.Primary !== undefined) {
33 | const averageColor = Blurhash.getAverageColor(
34 | data.ImageBlurHashes.Primary[
35 | Object.keys(data.ImageBlurHashes.Primary)[0]
36 | ],
37 | );
38 | setAverageColorRgb(
39 | `rgb(${averageColor?.r}, ${averageColor?.g}, ${averageColor?.b} )`,
40 | );
41 | }
42 | }
43 | }
44 | setBackGround();
45 | }, [data]);
46 |
47 | return (
48 | <>
49 |
50 | {data && !error && similarAlbums !== undefined ? (
51 | <>
52 |
59 | >
60 | ) : (
61 |
62 | )}
63 |
64 | >
65 | );
66 | };
67 |
68 | const styles = StyleSheet.create({
69 | container: {
70 | flex: 1,
71 | backgroundColor: colours.black,
72 | },
73 | });
74 |
75 | export {MovieScreen};
76 |
--------------------------------------------------------------------------------
/app/components/External/TextTicker/index.d.ts:
--------------------------------------------------------------------------------
1 | /*
2 | MIT License
3 |
4 | Copyright (c) 2018 Dean Hetherington
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
24 | https://github.com/deanhet/react-native-text-ticker
25 | */
26 |
27 | declare module 'react-native-text-ticker' {
28 | import React from 'react';
29 | import {StyleProp, TextProps, TextStyle, EasingFunction} from 'react-native';
30 |
31 | export interface TextTickerProps extends TextProps {
32 | duration?: number;
33 | onMarqueeComplete?: () => void;
34 | onScrollStart?: () => void;
35 | style?: StyleProp;
36 | loop?: boolean;
37 | bounce?: boolean;
38 | scroll?: boolean;
39 | marqueeOnMount?: boolean;
40 | marqueeDelay?: number;
41 | bounceDelay?: number;
42 | isInteraction?: boolean;
43 | useNativeDriver?: boolean;
44 | repeatSpacer?: number;
45 | easing?: EasingFunction;
46 | animationType?: 'auto' | 'scroll' | 'bounce';
47 | scrollSpeed?: number;
48 | bounceSpeed?: number;
49 | shouldAnimateTreshold?: number;
50 | isRTL?: boolean;
51 | bouncePadding?: {
52 | left?: number;
53 | right?: number;
54 | };
55 | disabled?: boolean;
56 | }
57 |
58 | export interface TextTickerRef {
59 | startAnimation(): void;
60 | stopAnimation(): void;
61 | }
62 |
63 | export default class TextTicker extends React.Component {}
64 | }
65 |
--------------------------------------------------------------------------------
/app/components/Search/RowNavigation.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Text, TouchableOpacity, View} from 'react-native';
3 | import FastImage from 'react-native-fast-image';
4 | import {NavigationProp} from '@react-navigation/native';
5 |
6 | import {styles} from '@boum/components/Search';
7 | import {LibraryItemList, Session} from '@boum/types';
8 |
9 | type RowNavigationProps = {
10 | albums: LibraryItemList;
11 | navigation: NavigationProp;
12 | navigationDestination: string;
13 | session: Session;
14 | };
15 |
16 | const RowNavigation: React.FC = ({
17 | albums,
18 | navigation,
19 | navigationDestination,
20 | session,
21 | }) => {
22 | return (
23 | <>
24 |
25 | {albums !== undefined && albums.TotalRecordCount >= 1 ? (
26 | <>
27 |
28 | {navigationDestination}
29 |
30 | {albums.Items.map(item => (
31 | <>
32 |
35 | navigation.push(navigationDestination, {
36 | itemId: item.Id,
37 | name: item.Name,
38 | item: item,
39 | })
40 | }
41 | style={styles.resultContainer}>
42 | <>
43 | {item.Id != null ? (
44 |
53 | ) : null}
54 |
58 | {item.Name}
59 |
60 | >
61 |
62 | >
63 | ))}
64 | >
65 | ) : null}
66 |
67 | >
68 | );
69 | };
70 | export {RowNavigation};
71 |
--------------------------------------------------------------------------------
/app/screens/VideoScreen.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useState} from 'react';
2 | import {BackHandler} from 'react-native';
3 | import {NavigationProp} from 'react-navigation';
4 | import {RouteProp} from '@react-navigation/native';
5 |
6 | import {VideoPlayer} from '@boum/components/Video';
7 | import {LoadingSpinner} from '@boum/components/Generic';
8 | import {useGetPlaybackInfo, useStore} from '@boum/hooks';
9 | import {MediaItem, Session} from '@boum/types';
10 |
11 | type VideoScreenProps = {
12 | navigation: NavigationProp;
13 | route: RouteProp<{params: {item: MediaItem; session: Session}}>;
14 | };
15 |
16 | const VideoScreen: React.FC = ({navigation, route}) => {
17 | const {item, session} = route.params;
18 | const jellyfin = useStore.getState().jellyfinClient;
19 |
20 | const [bitrate, setBitrate] = useState(session.maxBitrateVideo);
21 | const [videoProgress, setVideoProgress] = useState(0);
22 |
23 | const {playbackInfo, textStreams, sourceList} = useGetPlaybackInfo(
24 | item,
25 | session,
26 | bitrate,
27 | 0,
28 | );
29 |
30 | useEffect(() => {
31 | // Post to '/Sessions/Playing/Stop' when stopping playback.
32 | const backAction = () => {
33 | if (
34 | playbackInfo !== undefined &&
35 | playbackInfo.Mediasources !== undefined
36 | ) {
37 | jellyfin.postProgressUpdate(
38 | session,
39 | {playableDuration: 10, currentTime: videoProgress},
40 | true,
41 | playbackInfo?.PlaySessionId,
42 | 'Direct',
43 | bitrate,
44 | playbackInfo?.MediaSources[0]?.Id,
45 | 'Stop',
46 | );
47 | }
48 | navigation.goBack();
49 | return true;
50 | };
51 | const backHandler = BackHandler.addEventListener(
52 | 'hardwareBackPress',
53 | backAction,
54 | );
55 | return () => backHandler.remove();
56 | }, [bitrate, jellyfin, navigation, playbackInfo, session, videoProgress]);
57 |
58 | return (
59 | <>
60 | {playbackInfo && sourceList && textStreams ? (
61 |
70 | ) : (
71 |
72 | )}
73 | >
74 | );
75 | };
76 |
77 | export {VideoScreen};
78 |
--------------------------------------------------------------------------------
/app/components/Artist/ArtistHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Dimensions, StyleSheet, Text, View} from 'react-native';
3 | import FastImage from 'react-native-fast-image';
4 | import LinearGradient from 'react-native-linear-gradient';
5 |
6 | import SingleItemHeader from '@boum/components/SingleItemHeader';
7 | import {colours} from '@boum/constants';
8 | import {MediaItem, Session} from '@boum/types';
9 |
10 | const width = Dimensions.get('window').width;
11 |
12 | type ArtistHeaderProps = {
13 | artistItems: Array;
14 | item: MediaItem;
15 | session: Session;
16 | averageColorRgb: string;
17 | navigation: any;
18 | };
19 |
20 | const ArtistHeader: React.FC = ({
21 | artistItems,
22 | item,
23 | session,
24 | averageColorRgb,
25 | navigation,
26 | }) => {
27 | return (
28 | <>
29 | {averageColorRgb !== '' ? (
30 |
34 |
35 |
40 |
41 | ) : (
42 |
43 |
48 |
49 | )}
50 | >
51 | );
52 | };
53 |
54 | class ArtistHeaderContent extends React.PureComponent {
55 | render() {
56 | return (
57 | <>
58 | {this.props.item.Id != null ? (
59 |
68 | ) : null}
69 | {this.props.item.Name}
70 | >
71 | );
72 | }
73 | }
74 |
75 | const styles = StyleSheet.create({
76 | artistTitle: {
77 | color: colours.white,
78 | fontSize: 30,
79 | fontWeight: '600',
80 | paddingTop: 8,
81 | paddingLeft: 20,
82 | paddingRight: 20,
83 | textAlign: 'center',
84 | fontFamily: 'Inter-ExtraBold',
85 | },
86 | image: {
87 | width: width * 0.7,
88 | height: width * 0.7,
89 | alignSelf: 'center',
90 | },
91 | });
92 |
93 | export default ArtistHeader;
94 |
--------------------------------------------------------------------------------
/app/lib/settings/login.ts:
--------------------------------------------------------------------------------
1 | import uuid from 'react-native-uuid';
2 |
3 | import {versionBoum} from '@boum/constants';
4 | import {useStore} from '@boum/hooks';
5 | import {storeEncryptedValue} from '@boum/lib/encryptedStorage/encryptedStorage';
6 | import {Session} from '@boum/types';
7 |
8 | const login = async (
9 | hostname: string,
10 | username: string,
11 | password: string,
12 | setLoginStatus: (string: string) => void,
13 | ) => {
14 | const deviceId = uuid.v4();
15 | const response = await getToken(
16 | hostname,
17 | username,
18 | password,
19 | deviceId,
20 | setLoginStatus,
21 | );
22 |
23 | if (response.error == null) {
24 | return "Coudln't login.";
25 | } else {
26 | const res = response.res;
27 | const item: Session = {
28 | hostname: hostname,
29 | accessToken: res.AccessToken,
30 | userId: res.SessionInfo.UserId,
31 | username: res.SessionInfo.UserName,
32 | maxBitrateWifi: 140000000,
33 | maxBitrateMobile: 140000000,
34 | maxBitrateVideo: 100000000,
35 | maxBitrateDownloadAudio: 140000000,
36 | deviceId: deviceId,
37 | deviceName: 'boum ' + deviceId,
38 | chromecastAdress: null,
39 | chromecastAdressEnabled: false,
40 | };
41 |
42 | try {
43 | await storeEncryptedValue('user_session', JSON.stringify(item));
44 | useStore.setState({session: item});
45 | useStore.setState({gotLoginStatus: true});
46 | } catch (error) {
47 | return 'Error in saving User session.';
48 | }
49 | }
50 | };
51 |
52 | const getToken = async (
53 | url: string,
54 | username: string,
55 | password: string,
56 | deviceId: string,
57 | setLoginStatus: (string: string) => void,
58 | ) => {
59 | const clientHeaders = `MediaBrowser Client="boum", Device="boum", DeviceId="${deviceId}", Version="${versionBoum}"`;
60 | return fetch(`${url}/Users/authenticatebyname`, {
61 | method: 'POST',
62 | cache: 'no-cache',
63 | headers: {
64 | Accept: 'application/json',
65 | 'Content-Type': 'application/json',
66 | 'X-Emby-Authorization': clientHeaders,
67 | },
68 | body: JSON.stringify({
69 | Username: username,
70 | Pw: password,
71 | }),
72 | })
73 | .then(response => {
74 | if (response.status === 401) {
75 | setLoginStatus('Wrong username / password');
76 | return;
77 | }
78 | return response.json();
79 | })
80 | .then(json => {
81 | return {res: json, error: false};
82 | })
83 | .catch(error => {
84 | console.error(error);
85 | setLoginStatus('Network request failed');
86 | return {res: error, error: true};
87 | });
88 | };
89 |
90 | export {login};
91 |
--------------------------------------------------------------------------------
/app/screens/ArtistScreen.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {ScrollView, StyleSheet} from 'react-native';
3 | import {NavigationProp, RouteProp} from '@react-navigation/native';
4 |
5 | import ArtistHeader from '@boum/components/Artist/ArtistHeader';
6 | import {ArtistItems} from '@boum/components/Artist';
7 | import {LoadingSpinner} from '@boum/components/Generic';
8 | import {colours} from '@boum/constants';
9 | import {useGetArtist, useStore} from '@boum/hooks';
10 | import {MediaItem} from '@boum/types';
11 |
12 | type ArtistScreenProps = {
13 | navigation: NavigationProp;
14 | route: RouteProp<{params: {item: MediaItem; itemId: string}}>;
15 | };
16 |
17 | const ArtistScreen: React.FC = ({navigation, route}) => {
18 | const {itemId, item} = route.params;
19 |
20 | const session = useStore(state => state.session);
21 |
22 | const {
23 | artistInfo,
24 | artistItems,
25 | similarArtists,
26 | appearsOnItems,
27 | averageColorRgb,
28 | } = useGetArtist(item, itemId, session);
29 |
30 | return (
31 |
32 | {artistInfo && artistItems && appearsOnItems && averageColorRgb ? (
33 | <>
34 | {/*
35 | Convert this to a Flatlist
36 | https://reactnative.dev/docs/optimizing-flatlist-configuration#use-getitemlayout
37 | */}
38 |
45 |
53 |
61 |
69 | >
70 | ) : (
71 |
72 | )}
73 |
74 | );
75 | };
76 |
77 | const styles = StyleSheet.create({
78 | container: {
79 | flex: 1,
80 | backgroundColor: colours.black,
81 | },
82 | });
83 |
84 | export {ArtistScreen};
85 |
--------------------------------------------------------------------------------