diff --git a/ui/package-lock.json b/ui/package-lock.json index 3b37333b1..006a12518 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2150,6 +2150,37 @@ } } }, + "@testing-library/react-hooks": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-5.1.0.tgz", + "integrity": "sha512-ChRyyA14e0CeVkWGp24v8q/IiWUqH+B8daRx4lGZme4dsudmMNWz+Qo2Q2NzbD2O5rAVXh2hSbS/KTKeqHYhkw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@types/react": ">=16.9.0", + "@types/react-dom": ">=16.9.0", + "@types/react-test-renderer": ">=16.9.0", + "filter-console": "^0.1.1", + "react-error-boundary": "^3.1.0" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.13.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.10.tgz", + "integrity": "sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "dev": true + } + } + }, "@testing-library/user-event": { "version": "12.6.2", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.6.2.tgz", @@ -2315,6 +2346,24 @@ } } }, + "@types/react-dom": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.2.tgz", + "integrity": "sha512-Icd9KEgdnFfJs39KyRyr0jQ7EKhq8U6CcHRMGAS45fp5qgUvxL3ujUCfWFttUK2UErqZNj97t9gsVPNAqcwoCg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-test-renderer": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz", + "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-transition-group": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz", @@ -6668,6 +6717,12 @@ } } }, + "filter-console": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/filter-console/-/filter-console-0.1.1.tgz", + "integrity": "sha512-zrXoV1Uaz52DqPs+qEwNJWJFAWZpYJ47UNmpN9q4j+/EYsz85uV0DC9k8tRND5kYmoVzL0W+Y75q4Rg8sRJCdg==", + "dev": true + }, "final-form": { "version": "4.20.1", "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.1.tgz", @@ -13423,6 +13478,32 @@ "prop-types": "^15.7.2" } }, + "react-error-boundary": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.1.tgz", + "integrity": "sha512-W3xCd9zXnanqrTUeViceufD3mIW8Ut29BUD+S2f0eO2XCOU8b6UrJfY46RDGe5lxCJzfe4j0yvIfh0RbTZhKJw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.13.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.10.tgz", + "integrity": "sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "dev": true + } + } + }, "react-error-overlay": { "version": "6.0.7", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.7.tgz", diff --git a/ui/package.json b/ui/package.json index 31f1c1508..c34eef3cd 100644 --- a/ui/package.json +++ b/ui/package.json @@ -30,7 +30,9 @@ "devDependencies": { "@testing-library/jest-dom": "^5.11.9", "@testing-library/react": "^11.2.3", + "@testing-library/react-hooks": "^5.1.0", "@testing-library/user-event": "^12.6.2", + "css-mediaquery": "^0.1.2", "prettier": "^2.2.1" }, "scripts": { diff --git a/ui/src/audioplayer/Player.js b/ui/src/audioplayer/Player.js index 267d0c8ff..d7ab5c723 100644 --- a/ui/src/audioplayer/Player.js +++ b/ui/src/audioplayer/Player.js @@ -15,11 +15,11 @@ import { setVolume, clearQueue, } from '../actions' -import themes from '../themes' import config from '../config' import PlayerToolbar from './PlayerToolbar' import { sendNotification, baseUrl } from '../utils' import { keyMap } from '../hotkeys' +import useCurrentTheme from '../themes/useCurrentTheme' const useStyle = makeStyles((theme) => ({ audioTitle: { @@ -58,8 +58,7 @@ const AudioTitle = React.memo(({ audioInfo, isMobile, className }) => { const Player = () => { const translate = useTranslate() - const currentTheme = useSelector((state) => state.theme) - const theme = themes[currentTheme] || themes.DarkTheme + const theme = useCurrentTheme() const playerTheme = (theme.player && theme.player.theme) || 'dark' const dataProvider = useDataProvider() const dispatch = useDispatch() diff --git a/ui/src/consts.js b/ui/src/consts.js index 77d107519..3dc6bcbf2 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -1,3 +1,5 @@ export const REST_URL = '/app/api' export const M3U_MIME_TYPE = 'audio/x-mpegurl' + +export const AUTO_THEME_ID = 'AUTO_THEME_ID' diff --git a/ui/src/layout/Layout.js b/ui/src/layout/Layout.js index b6c53ab91..f7d00fd8d 100644 --- a/ui/src/layout/Layout.js +++ b/ui/src/layout/Layout.js @@ -6,14 +6,14 @@ import { HotKeys } from 'react-hotkeys' import Menu from './Menu' import AppBar from './AppBar' import Notification from './Notification' -import themes from '../themes' +import useCurrentTheme from '../themes/useCurrentTheme' const useStyles = makeStyles({ root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) }, }) export default (props) => { - const theme = useSelector((state) => themes[state.theme] || themes.DarkTheme) + const theme = useCurrentTheme() const queue = useSelector((state) => state.queue) const classes = useStyles({ addPadding: queue.queue.length > 0 }) const dispatch = useDispatch() diff --git a/ui/src/personal/Personal.js b/ui/src/personal/Personal.js index 44ae50d18..a6af16d18 100644 --- a/ui/src/personal/Personal.js +++ b/ui/src/personal/Personal.js @@ -23,6 +23,7 @@ import themes from '../themes' import { docsUrl } from '../utils' import { useGetLanguageChoices } from '../i18n' import albumLists, { defaultAlbumList } from '../album/albumLists' +import { AUTO_THEME_ID } from '../consts' const useStyles = makeStyles({ root: { marginTop: '1em' }, @@ -77,9 +78,17 @@ const SelectTheme = (props) => { const translate = useTranslate() const dispatch = useDispatch() const currentTheme = useSelector((state) => state.theme) - const themeChoices = Object.keys(themes).map((key) => { - return { id: key, name: themes[key].themeName } - }) + const themeChoices = [ + { + id: AUTO_THEME_ID, + name: 'Auto', + }, + ] + themeChoices.push( + ...Object.keys(themes).map((key) => { + return { id: key, name: themes[key].themeName } + }) + ) themeChoices.push({ id: helpKey, name: , diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js new file mode 100644 index 000000000..e25cfd1fd --- /dev/null +++ b/ui/src/themes/useCurrentTheme.js @@ -0,0 +1,14 @@ +import { useSelector } from 'react-redux' +import useMediaQuery from '@material-ui/core/useMediaQuery' +import themes from './index' +import { AUTO_THEME_ID } from '../consts' + +export default () => { + const prefersLightMode = useMediaQuery('(prefers-color-scheme: light)') + return useSelector((state) => { + if (state.theme === AUTO_THEME_ID) { + return prefersLightMode ? themes.LightTheme : themes.DarkTheme + } + return themes[state.theme] || themes.DarkTheme + }) +} diff --git a/ui/src/themes/useCurrentTheme.test.js b/ui/src/themes/useCurrentTheme.test.js new file mode 100644 index 000000000..03775d34f --- /dev/null +++ b/ui/src/themes/useCurrentTheme.test.js @@ -0,0 +1,120 @@ +import React from 'react' +import { Provider } from 'react-redux' +import { createStore } from 'redux' +import mediaQuery from 'css-mediaquery' +import { renderHook } from '@testing-library/react-hooks' +import useCurrentTheme from './useCurrentTheme' +import { themeReducer } from '../reducers/themeReducer' +import { AUTO_THEME_ID } from '../consts' + +function createMatchMedia(theme) { + return (query) => ({ + matches: mediaQuery.match(query, { 'prefers-color-scheme': theme }), + addListener: () => {}, + removeListener: () => {}, + }) +} + +describe('useCurrentTheme', () => { + describe('with user preference theme as light', () => { + beforeAll(() => { + window.matchMedia = createMatchMedia('light') + }) + it('sets theme as light in auto mode', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + expect(result.current.themeName).toMatch('Light') + }) + it('sets theme as dark', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(result.current.themeName).toMatch('Dark') + }) + it('sets theme as light', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(result.current.themeName).toMatch('Light') + }) + it('sets theme as spotify-ish', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(result.current.themeName).toMatch('Spotify-ish') + }) + }) + describe('with user preference theme as dark', () => { + beforeAll(() => { + window.matchMedia = createMatchMedia('dark') + }) + it('sets theme as dark in auto mode', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(result.current.themeName).toMatch('Dark') + }) + it('sets theme as dark', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(result.current.themeName).toMatch('Dark') + }) + it('sets theme as light', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(result.current.themeName).toMatch('Light') + }) + it('sets theme as spotify-ish', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + expect(result.current.themeName).toMatch('Spotify-ish') + }) + }) +})