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/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index b8e429333..9bb214f3d 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -153,3 +153,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/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/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 8c844d8da..f6f772875 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -120,6 +120,7 @@ const commands = { _package: 'volume', plugin: 'ctrl', method: 'set_volume', + argKeys: ['volume'], }, getVolume: { _package: 'volume', @@ -250,6 +251,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;