diff --git a/.github/workflows/codeql-analysis_v3.yml b/.github/workflows/codeql-analysis_v3.yml
index d06cce1a5..a284d7691 100644
--- a/.github/workflows/codeql-analysis_v3.yml
+++ b/.github/workflows/codeql-analysis_v3.yml
@@ -38,7 +38,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
@@ -51,9 +51,9 @@ jobs:
python -m pip install --upgrade pip
pip install -r requirements.txt
- # Set the `CODEQL-PYTHON` environment variable to the Python executable
+ # Set the `CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION` environment variable to the Python executable
# that includes the dependencies
- echo "CODEQL_PYTHON=$(which python)" >> $GITHUB_ENV
+ echo "CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION=$(which python)" >> $GITHUB_ENV
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
diff --git a/.github/workflows/pythonpackage_future3.yml b/.github/workflows/pythonpackage_future3.yml
index da7007b97..3bc2c1174 100644
--- a/.github/workflows/pythonpackage_future3.yml
+++ b/.github/workflows/pythonpackage_future3.yml
@@ -22,9 +22,9 @@ jobs:
python-version: ['3.9', '3.10', '3.11', '3.12']
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
diff --git a/docker/Dockerfile.jukebox b/docker/Dockerfile.jukebox
index 3d63a4a02..194e2efdc 100644
--- a/docker/Dockerfile.jukebox
+++ b/docker/Dockerfile.jukebox
@@ -44,7 +44,7 @@ RUN pip install --no-cache-dir -r ${INSTALLATION_PATH}/requirements.txt
ENV ZMQ_PREFIX /opt/libzmq
ENV ZMQ_DRAFT_API 1
COPY --from=libzmq ${ZMQ_PREFIX} ${ZMQ_PREFIX}
-RUN pip install -v pyzmq --no-binary pyzmq
+RUN pip install -v "pyzmq<26" --no-binary pyzmq
EXPOSE 5555 5556
WORKDIR ${INSTALLATION_PATH}/src/jukebox
diff --git a/documentation/builders/README.md b/documentation/builders/README.md
index 512d26ed0..29733e92b 100644
--- a/documentation/builders/README.md
+++ b/documentation/builders/README.md
@@ -30,6 +30,8 @@
## Web Application
+* Application
+ * [Cover Art](./webapp/cover-art.md)
* Music
* [Playlists, Livestreams and Podcasts](./webapp/playlists-livestreams-podcasts.md)
diff --git a/documentation/builders/webapp/cover-art.md b/documentation/builders/webapp/cover-art.md
new file mode 100644
index 000000000..09bae3a30
--- /dev/null
+++ b/documentation/builders/webapp/cover-art.md
@@ -0,0 +1,37 @@
+# Cover Art
+
+## Enable/Disable Cover Art
+
+The Web App automatically searches for cover art for albums and songs. If it finds cover art, it displays it; if not, it shows a placeholder image. However, you may prefer to disable cover art (e.g. in situations where device performance is low; screen space is limited; etc). There are two ways to do this:
+
+1. **Web App Settings**: Go to the "Settings" tab. Under the "General" section, find and toggle the "Show Cover Art" option.
+1. **Configuration File**: Open the `jukebox.yaml` file. Navigate to `webapp` -> `show_covers`. Set this value to `true` to enable or `false` to disable cover art display. If this option does not exist, it assumes `true` as a default.
+
+## Providing Additional Cover Art
+
+Cover art can be provided in two ways: 1) embedded within the audio file itself, or 2) as a separate image file in the same directory as the audio file. The software searches for cover art in the order listed.
+
+To add cover art using the file system, place a file named `cover.jpg` in the same folder as your audio file or album. Accepted image file types are `jpg` and `png`.
+
+### Example
+
+Suppose none of your files currently include embedded cover art, the example below demonstrates how to enable cover art for an entire folder, applying the same cover art to all files within that folder.
+
+> [!IMPORTANT]
+> You cannot assign different cover arts to different tracks within the same folder.
+
+#### Example Folder Structure
+
+```text
+└── audiofolders
+ ├── Simone Sommerland
+ │ ├── 01 Aramsamsam.mp3
+ │ ├── 02 Das Rote Pferd.mp3
+ │ ├── 03 Hoch am Himmel.mp3
+ │ └── cover.jpg <- Cover Art file as JPG
+ └── Bibi und Tina
+ ├── 01 Bibi und Tina Song.mp3
+ ├── 02 Alles geht.mp3
+ ├── 03 Solange dein Herz spricht.mp3
+ └── cover.png <- Cover Art file as PNG
+```
diff --git a/documentation/developers/docker.md b/documentation/developers/docker.md
index 74d95c112..6a0af80c7 100644
--- a/documentation/developers/docker.md
+++ b/documentation/developers/docker.md
@@ -123,7 +123,7 @@ They can be run individually or in combination. To do that, we use
load-module module-native-protocol-tcp auth-ip-acl=127.0.0.1
```
-1. Edit `$INSTALL_DIR/etc/pulse//etc/pulse/daemon.conf`, find the
+1. Edit `$INSTALL_DIR/etc/pulse/daemon.conf`, find the
following line and change it to:
``` bash
diff --git a/installation/routines/setup_jukebox_core.sh b/installation/routines/setup_jukebox_core.sh
index d93bbf8c5..2459ffb5e 100644
--- a/installation/routines/setup_jukebox_core.sh
+++ b/installation/routines/setup_jukebox_core.sh
@@ -86,7 +86,7 @@ _jukebox_core_build_and_install_pyzmq() {
fi
ZMQ_PREFIX="${JUKEBOX_ZMQ_PREFIX}" ZMQ_DRAFT_API=1 \
- pip install -v pyzmq --no-binary pyzmq
+ pip install -v 'pyzmq<26' --no-binary pyzmq
else
print_lc " Skipping. pyzmq already installed"
fi
@@ -121,6 +121,20 @@ _jukebox_core_check() {
local pip_modules=$(get_args_from_file "${INSTALLATION_PATH}/requirements.txt")
verify_pip_modules pyzmq $pip_modules
+ log " Verify ZMQ version '${JUKEBOX_ZMQ_VERSION}'"
+ local zmq_version=$(python -c 'import zmq; print(f"{zmq.zmq_version()}")')
+ if [[ "${zmq_version}" != "${JUKEBOX_ZMQ_VERSION}" ]]; then
+ exit_on_error "ERROR: ZMQ version '${zmq_version}' differs from expected '${JUKEBOX_ZMQ_VERSION}'!"
+ fi
+ log " CHECK"
+
+ log " Verify ZMQ has 'DRAFT-API' activated"
+ local zmq_hasDraftApi=$(python -c 'import zmq; print(f"{zmq.DRAFT_API}")')
+ if [[ "${zmq_hasDraftApi}" != "True" ]]; then
+ exit_on_error "ERROR: ZMQ has 'DRAFT-API' '${zmq_hasDraftApi}' differs from expected 'True'!"
+ fi
+ log " CHECK"
+
verify_files_chmod_chown 644 "${CURRENT_USER}" "${CURRENT_USER_GROUP}" "${JUKEBOX_PULSE_CONFIG}"
verify_files_chmod_chown 644 "${CURRENT_USER}" "${CURRENT_USER_GROUP}" "${SETTINGS_PATH}/jukebox.yaml"
diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml
index b03f38660..5a94b3e5b 100644
--- a/resources/default-settings/jukebox.default.yaml
+++ b/resources/default-settings/jukebox.default.yaml
@@ -136,3 +136,7 @@ sync_rfidcards:
config_file: ../../shared/settings/sync_rfidcards.yaml
webapp:
coverart_cache_path: ../../src/webapp/build/cover-cache
+ # Load cover arts in Webapp. Change to false in case you have performance issue
+ # when handling a lot of music
+ # Defaults to true
+ show_covers: true
diff --git a/src/jukebox/components/misc.py b/src/jukebox/components/misc.py
index 9995509aa..2cc260d79 100644
--- a/src/jukebox/components/misc.py
+++ b/src/jukebox/components/misc.py
@@ -8,8 +8,10 @@
import jukebox.plugs as plugin
import jukebox.utils
from jukebox.daemon import get_jukebox_daemon
+import jukebox.cfghandler
logger = logging.getLogger('jb.misc')
+cfg = jukebox.cfghandler.get_handler('jukebox')
@plugin.register
@@ -105,3 +107,20 @@ def empty_rpc_call(msg: str = ''):
"""
if msg:
logger.warning(msg)
+
+
+@plugin.register
+def get_app_settings():
+ """Return settings for web app stored in jukebox.yaml"""
+ show_covers = cfg.setndefault('webapp', 'show_covers', value=True)
+
+ return {
+ 'show_covers': show_covers
+ }
+
+
+@plugin.register
+def set_app_settings(settings={}):
+ """Set configuration settings for the web app."""
+ for key, value in settings.items():
+ cfg.setn('webapp', key, value=value)
diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py
index dcbef2ea8..86dbc60ab 100644
--- a/src/jukebox/components/playermpd/__init__.py
+++ b/src/jukebox/components/playermpd/__init__.py
@@ -156,6 +156,31 @@ def __init__(self):
self.second_swipe_action = None
self.decode_2nd_swipe_option()
+ self.end_of_playlist_next_action = utils.get_config_action(cfg,
+ 'playermpd',
+ 'end_of_playlist_next_action',
+ 'none',
+ {'rewind': self.rewind,
+ 'stop': self.stop,
+ 'none': lambda: None},
+ logger)
+ self.stopped_prev_action = utils.get_config_action(cfg,
+ 'playermpd',
+ 'stopped_prev_action',
+ 'prev',
+ {'rewind': self.rewind,
+ 'prev': self._prev_in_stopped_state,
+ 'none': lambda: None},
+ logger)
+ self.stopped_next_action = utils.get_config_action(cfg,
+ 'playermpd',
+ 'stopped_next_action',
+ 'next',
+ {'rewind': self.rewind,
+ 'next': self._next_in_stopped_state,
+ 'none': lambda: None},
+ logger)
+
self.mpd_client = mpd.MPDClient()
self.coverart_cache_manager = CoverartCacheManager()
@@ -327,15 +352,48 @@ def pause(self, state: int = 1):
@plugs.tag
def prev(self):
logger.debug("Prev")
+ if self.mpd_status['state'] == 'stop':
+ logger.debug('Player is stopped, calling stopped_prev_action')
+ return self.stopped_prev_action()
+ try:
+ with self.mpd_lock:
+ self.mpd_client.previous()
+ except mpd.base.CommandError:
+ # This shouldn't happen in reality, but we still catch
+ # this error to avoid crashing the player thread:
+ logger.warning('Failed to go to previous song, ignoring')
+
+ def _prev_in_stopped_state(self):
with self.mpd_lock:
- self.mpd_client.previous()
+ self.mpd_client.play(max(0, int(self.mpd_status['pos']) - 1))
@plugs.tag
def next(self):
"""Play next track in current playlist"""
logger.debug("Next")
+ if self.mpd_status['state'] == 'stop':
+ logger.debug('Player is stopped, calling stopped_next_action')
+ return self.stopped_next_action()
+ playlist_len = int(self.mpd_status.get('playlistlength', -1))
+ current_pos = int(self.mpd_status.get('pos', 0))
+ if current_pos == playlist_len - 1:
+ logger.debug(f'next() called during last song ({current_pos}) of '
+ f'playlist (len={playlist_len}), running end_of_playlist_next_action.')
+ return self.end_of_playlist_next_action()
+ try:
+ with self.mpd_lock:
+ self.mpd_client.next()
+ except mpd.base.CommandError:
+ # This shouldn't happen in reality, but we still catch
+ # this error to avoid crashing the player thread:
+ logger.warning('Failed to go to next song, ignoring')
+
+ def _next_in_stopped_state(self):
+ pos = int(self.mpd_status['pos']) + 1
+ if pos > int(self.mpd_status['playlistlength']) - 1:
+ return self.end_of_playlist_next_action()
with self.mpd_lock:
- self.mpd_client.next()
+ self.mpd_client.play(pos)
@plugs.tag
def seek(self, new_time):
diff --git a/src/jukebox/components/playermpd/coverart_cache_manager.py b/src/jukebox/components/playermpd/coverart_cache_manager.py
index bb2346497..f292a2bbe 100644
--- a/src/jukebox/components/playermpd/coverart_cache_manager.py
+++ b/src/jukebox/components/playermpd/coverart_cache_manager.py
@@ -47,7 +47,10 @@ def save_to_cache(self, mp3_file_path: str):
def _save_to_cache(self, mp3_file_path: str):
base_filename = Path(mp3_file_path).stem
cache_key = self.generate_cache_key(base_filename)
+
file_extension, data = self._extract_album_art(mp3_file_path)
+ if file_extension == NO_COVER_ART_EXTENSION: # Check if cover has been added as separate file in folder
+ file_extension, data = self._get_from_filesystem(mp3_file_path)
cache_filename = f"{cache_key}.{file_extension}"
full_path = self.cache_folder_path / cache_filename # Works due to Pathlib
@@ -67,9 +70,23 @@ def _extract_album_art(self, mp3_file_path: str) -> tuple:
for tag in audio_file.tags.values():
if isinstance(tag, APIC):
- mime_type = tag.mime
- file_extension = 'jpg' if mime_type == 'image/jpeg' else mime_type.split('/')[-1]
- return (file_extension, tag.data)
+ if tag.mime and tag.data:
+ file_extension = 'jpg' if tag.mime == 'image/jpeg' else tag.mime.split('/')[-1]
+ return (file_extension, tag.data)
+
+ return (NO_COVER_ART_EXTENSION, b'')
+
+ def _get_from_filesystem(self, mp3_file_path: str) -> tuple:
+ path = Path(mp3_file_path)
+ directory = path.parent
+ cover_files = list(directory.glob('Cover.*')) + list(directory.glob('cover.*'))
+
+ for file in cover_files:
+ if file.suffix.lower() in ['.jpg', '.jpeg', '.png']:
+ with file.open('rb') as img_file:
+ data = img_file.read()
+ file_extension = file.suffix[1:] # Get extension without dot
+ return (file_extension, data)
return (NO_COVER_ART_EXTENSION, b'')
diff --git a/src/jukebox/jukebox/utils.py b/src/jukebox/jukebox/utils.py
index dbd647490..4cc0270ae 100644
--- a/src/jukebox/jukebox/utils.py
+++ b/src/jukebox/jukebox/utils.py
@@ -183,6 +183,19 @@ def rpc_call_to_str(cfg_rpc_call: Dict, with_args=True) -> str:
return readable
+def get_config_action(cfg, section, option, default, valid_actions_dict, logger):
+ """
+ Looks up the given {section}.{option} config option and returns
+ the associated entry from valid_actions_dict, if valid. Falls back to the given
+ default otherwise.
+ """
+ action = cfg.setndefault(section, option, value='').lower()
+ if action not in valid_actions_dict:
+ logger.error(f"Config {section}.{option} must be one of {valid_actions_dict.keys()}. Using default '{default}'")
+ action = default
+ return valid_actions_dict[action]
+
+
def indent(doc, spaces=4):
lines = doc.split('\n')
for i in range(0, len(lines)):
diff --git a/src/webapp/public/locales/de/translation.json b/src/webapp/public/locales/de/translation.json
index d1a4391d6..7dbdcf695 100644
--- a/src/webapp/public/locales/de/translation.json
+++ b/src/webapp/public/locales/de/translation.json
@@ -219,6 +219,12 @@
"why": "Warum?",
"control-label": "Auto Hotspot"
},
+ "general": {
+ "title": "Allgmeine Einstellungen",
+ "show_covers": {
+ "title": "Cover anzeigen"
+ }
+ },
"timers": {
"option-label-timeslot": "{{value}} min",
"option-label-off": "Aus",
diff --git a/src/webapp/public/locales/en/translation.json b/src/webapp/public/locales/en/translation.json
index 74fd9a696..7ff66ecc4 100644
--- a/src/webapp/public/locales/en/translation.json
+++ b/src/webapp/public/locales/en/translation.json
@@ -219,6 +219,12 @@
"why": "Why?",
"control-label": "Auto Hotspot"
},
+ "general": {
+ "title": "General Settings",
+ "show_covers": {
+ "title": "Show Cover Art"
+ }
+ },
"timers": {
"option-label-timeslot": "{{value}} min",
"option-label-off": "Off",
diff --git a/src/webapp/src/App.js b/src/webapp/src/App.js
index 99272db64..a51529381 100644
--- a/src/webapp/src/App.js
+++ b/src/webapp/src/App.js
@@ -2,6 +2,7 @@ import React, { Suspense } from 'react';
import Grid from '@mui/material/Grid';
+import AppSettingsProvider from './context/appsettings';
import PubSubProvider from './context/pubsub';
import PlayerProvider from './context/player';
import Router from './router';
@@ -10,15 +11,17 @@ function App() {
return (
-
-
-
+
+
+
+
+
);
diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js
index fb17fbf17..ee8fb0250 100644
--- a/src/webapp/src/commands/index.js
+++ b/src/webapp/src/commands/index.js
@@ -121,6 +121,7 @@ const commands = {
_package: 'volume',
plugin: 'ctrl',
method: 'set_volume',
+ argKeys: ['volume'],
},
getVolume: {
_package: 'volume',
@@ -251,6 +252,18 @@ const commands = {
argKeys: ['option'],
},
+ // Misc
+ getAppSettings: {
+ _package: 'misc',
+ plugin: 'get_app_settings'
+ },
+
+ setAppSettings: {
+ _package: 'misc',
+ plugin: 'set_app_settings',
+ argKeys: ['settings'],
+ },
+
// Synchronisation
'sync_rfidcards_all': {
_package: 'sync_rfidcards',
diff --git a/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js b/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js
index 2c6d99180..71f6ba315 100644
--- a/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js
+++ b/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js
@@ -1,4 +1,4 @@
-import React, { forwardRef, useEffect, useState } from 'react';
+import React, { forwardRef, useContext, useEffect, useState } from 'react';
import {
Link,
useLocation,
@@ -15,6 +15,7 @@ import {
import noCover from '../../../../../assets/noCover.jpg';
+import AppSettingsContext from '../../../../../context/appsettings/context';
import request from '../../../../../utils/request';
const AlbumListItem = ({ albumartist, album, isButton = true }) => {
@@ -22,6 +23,14 @@ const AlbumListItem = ({ albumartist, album, isButton = true }) => {
const { search: urlSearch } = useLocation();
const [coverImage, setCoverImage] = useState(noCover);
+ const {
+ settings,
+ } = useContext(AppSettingsContext);
+
+ const {
+ show_covers,
+ } = settings;
+
useEffect(() => {
const getCoverArt = async () => {
const { result } = await request('getAlbumCoverArt', {
@@ -35,7 +44,7 @@ const AlbumListItem = ({ albumartist, album, isButton = true }) => {
};
}
- if (albumartist && album) {
+ if (albumartist && album && show_covers) {
getCoverArt();
}
}, [albumartist, album]);
@@ -61,9 +70,11 @@ const AlbumListItem = ({ albumartist, album, isButton = true }) => {
key={album}
>
-
-
-
+ {show_covers &&
+
+
+
+ }
{
const [coverImage, setCoverImage] = useState(undefined);
const [backgroundImage, setBackgroundImage] = useState('none');
+ const {
+ settings,
+ } = useContext(AppSettingsContext);
+
+ const { show_covers } = settings;
+
useEffect(() => {
const getCoverArt = async () => {
const { result } = await request('getSingleCoverArt', { song_url: file });
@@ -30,7 +37,7 @@ const Player = () => {
};
}
- if (file) {
+ if (file && show_covers) {
getCoverArt();
}
}, [file]);
diff --git a/src/webapp/src/components/Settings/general/index.js b/src/webapp/src/components/Settings/general/index.js
new file mode 100644
index 000000000..790043778
--- /dev/null
+++ b/src/webapp/src/components/Settings/general/index.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useTheme } from '@mui/material/styles';
+
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ Divider,
+ Grid,
+} from '@mui/material';
+import ShowCovers from './show-covers';
+
+const SettingsGeneral = () => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const spacer = { marginBottom: theme.spacing(2) }
+
+ return (
+
+
+
+
+ .MuiGrid-root:not(:last-child)': spacer }}
+ >
+
+
+
+
+ );
+};
+
+export default SettingsGeneral;
diff --git a/src/webapp/src/components/Settings/general/show-covers.js b/src/webapp/src/components/Settings/general/show-covers.js
new file mode 100644
index 000000000..a3b31f4e0
--- /dev/null
+++ b/src/webapp/src/components/Settings/general/show-covers.js
@@ -0,0 +1,56 @@
+import React, { useContext } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+ Box,
+ Grid,
+ Switch,
+ Typography,
+} from '@mui/material';
+
+import AppSettingsContext from '../../../context/appsettings/context';
+import request from '../../../utils/request';
+
+const ShowCovers = () => {
+ const { t } = useTranslation();
+
+ const {
+ settings,
+ setSettings,
+ } = useContext(AppSettingsContext);
+
+ const {
+ show_covers,
+ } = settings;
+
+ const updateShowCoversSetting = async (show_covers) => {
+ await request('setAppSettings', { settings: { show_covers }});
+ }
+
+ const handleSwitch = (event) => {
+ setSettings({ show_covers: event.target.checked});
+ updateShowCoversSetting(event.target.checked);
+ }
+
+ return (
+
+
+
+ {t(`settings.general.show_covers.title`)}
+
+
+
+
+
+
+ );
+};
+
+export default ShowCovers;
diff --git a/src/webapp/src/components/Settings/index.js b/src/webapp/src/components/Settings/index.js
index 1bc599fc1..75ce7840f 100644
--- a/src/webapp/src/components/Settings/index.js
+++ b/src/webapp/src/components/Settings/index.js
@@ -2,9 +2,10 @@ import React from 'react';
import { Grid } from '@mui/material';
+import SettingsAudio from './audio/index';
import SettingsAutoHotspot from './autohotspot';
+import SettingsGeneral from './general';
import SettingsSecondSwipe from './secondswipe';
-import SettingsAudio from './audio/index';
import SettingsStatus from './status/index';
import SettingsTimers from './timers/index';
import SystemControls from './systemcontrols';
@@ -28,6 +29,9 @@ const Settings = () => {
+
+
+
diff --git a/src/webapp/src/context/appsettings/context.js b/src/webapp/src/context/appsettings/context.js
new file mode 100644
index 000000000..f2650d210
--- /dev/null
+++ b/src/webapp/src/context/appsettings/context.js
@@ -0,0 +1,7 @@
+import { createContext } from 'react';
+
+const AppSettingsContext = createContext({
+ showCovers: true,
+});
+
+export default AppSettingsContext;
diff --git a/src/webapp/src/context/appsettings/index.js b/src/webapp/src/context/appsettings/index.js
new file mode 100644
index 000000000..1fa34914d
--- /dev/null
+++ b/src/webapp/src/context/appsettings/index.js
@@ -0,0 +1,33 @@
+import React, { useEffect, useState } from 'react';
+
+import AppSettingsContext from './context';
+import request from '../../utils/request';
+
+const AppSettingsProvider = ({ children }) => {
+ const [settings, setSettings] = useState({});
+
+ useEffect(() => {
+ const loadAppSettings = async () => {
+ const { result, error } = await request('getAppSettings');
+ if(result) setSettings(result);
+ if(error) {
+ console.error('Error loading AppSettings');
+ }
+ }
+
+ loadAppSettings();
+ }, []);
+
+ const context = {
+ setSettings,
+ settings,
+ };
+
+ return(
+
+ { children }
+
+ )
+};
+
+export default AppSettingsProvider;