diff --git a/src/RootApp.tsx b/src/RootApp.tsx index 08c7b81dce..afd9831474 100644 --- a/src/RootApp.tsx +++ b/src/RootApp.tsx @@ -1,5 +1,4 @@ import loadable from '@loadable/component'; -import { ThemeProvider } from '@mui/material/styles'; import { History } from '@remix-run/router'; import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; @@ -7,7 +6,6 @@ import React from 'react'; import { ApiProvider } from 'hooks/useApi'; import { WebConfigProvider } from 'hooks/useWebConfig'; -import theme from 'themes/theme'; import { queryClient } from 'utils/query/queryClient'; const StableAppRouter = loadable(() => import('./apps/stable/AppRouter')); @@ -21,12 +19,10 @@ const RootApp = ({ history }: Readonly<{ history: History }>) => { - - {isExperimentalLayout ? - : - - } - + {isExperimentalLayout ? + : + + } diff --git a/src/RootAppRouter.tsx b/src/RootAppRouter.tsx index 4f98fb86f4..1fc2293703 100644 --- a/src/RootAppRouter.tsx +++ b/src/RootAppRouter.tsx @@ -12,6 +12,7 @@ import AppHeader from 'components/AppHeader'; import Backdrop from 'components/Backdrop'; import { useLegacyRouterSync } from 'hooks/useLegacyRouterSync'; import { DASHBOARD_APP_ROUTES } from 'apps/dashboard/routes/routes'; +import UserThemeProvider from 'themes/UserThemeProvider'; const router = createHashRouter([ { @@ -35,11 +36,11 @@ export default function RootAppRouter({ history }: Readonly<{ history: History}> */ function RootAppLayout() { return ( - <> + - + ); } diff --git a/src/apps/experimental/routes/user/display/DisplayPreferences.tsx b/src/apps/experimental/routes/user/display/DisplayPreferences.tsx index 0645edbd4f..4fe869428e 100644 --- a/src/apps/experimental/routes/user/display/DisplayPreferences.tsx +++ b/src/apps/experimental/routes/user/display/DisplayPreferences.tsx @@ -12,10 +12,11 @@ import React, { Fragment } from 'react'; import { appHost } from 'components/apphost'; import { useApi } from 'hooks/useApi'; +import { useThemes } from 'hooks/useThemes'; import globalize from 'scripts/globalize'; + import { DisplaySettingsValues } from './types'; import { useScreensavers } from './hooks/useScreensavers'; -import { useServerThemes } from './hooks/useServerThemes'; interface DisplayPreferencesProps { onChange: (event: SelectChangeEvent | React.SyntheticEvent) => void; @@ -25,7 +26,7 @@ interface DisplayPreferencesProps { export function DisplayPreferences({ onChange, values }: Readonly) { const { user } = useApi(); const { screensavers } = useScreensavers(); - const { themes } = useServerThemes(); + const { themes } = useThemes(); return ( diff --git a/src/apps/experimental/routes/user/display/hooks/useServerThemes.ts b/src/apps/experimental/routes/user/display/hooks/useServerThemes.ts deleted file mode 100644 index 4a1cde3a1e..0000000000 --- a/src/apps/experimental/routes/user/display/hooks/useServerThemes.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; - -import themeManager from 'scripts/themeManager'; -import { Theme } from 'types/webConfig'; - -export function useServerThemes() { - const [themes, setThemes] = useState(); - - useEffect(() => { - async function getServerThemes() { - const loadedThemes = await themeManager.getThemes(); - - setThemes(loadedThemes ?? []); - } - - if (!themes) { - void getServerThemes(); - } - // We've intentionally left the dependency array here to ensure that the effect happens only once. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const defaultTheme = useMemo(() => { - if (!themes) return null; - return themes.find((theme) => theme.default); - }, [themes]); - - return { - themes: themes ?? [], - defaultTheme - }; -} diff --git a/src/apps/stable/AppRouter.tsx b/src/apps/stable/AppRouter.tsx index 0917e6aa19..40b5c2aa39 100644 --- a/src/apps/stable/AppRouter.tsx +++ b/src/apps/stable/AppRouter.tsx @@ -7,6 +7,7 @@ import { STABLE_APP_ROUTES } from './routes/routes'; import Backdrop from 'components/Backdrop'; import AppHeader from 'components/AppHeader'; import { DASHBOARD_APP_PATHS, DASHBOARD_APP_ROUTES } from 'apps/dashboard/routes/routes'; +import UserThemeProvider from 'themes/UserThemeProvider'; const router = createHashRouter([{ element: , @@ -32,11 +33,11 @@ function StableAppLayout() { .some(path => location.pathname.startsWith(`/${path}`)); return ( - <> + - + ); } diff --git a/src/hooks/useThemes.ts b/src/hooks/useThemes.ts new file mode 100644 index 0000000000..5ece5a1d68 --- /dev/null +++ b/src/hooks/useThemes.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; + +import { useWebConfig } from './useWebConfig'; + +export function useThemes() { + const { themes } = useWebConfig(); + + const defaultTheme = useMemo(() => { + return themes?.find(theme => theme.default); + }, [ themes ]); + + return { + themes: themes || [], + defaultTheme + }; +} diff --git a/src/hooks/useUserTheme.ts b/src/hooks/useUserTheme.ts new file mode 100644 index 0000000000..ec0c584007 --- /dev/null +++ b/src/hooks/useUserTheme.ts @@ -0,0 +1,57 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { currentSettings as userSettings } from 'scripts/settings/userSettings'; +import Events from 'utils/events'; + +import { useApi } from './useApi'; +import { useThemes } from './useThemes'; + +const THEME_FIELD_NAMES = [ 'appTheme', 'dashboardTheme' ]; + +export function useUserTheme() { + const [ theme, setTheme ] = useState(); + const [ dashboardTheme, setDashboardTheme ] = useState(); + + const { user } = useApi(); + const { defaultTheme } = useThemes(); + + useEffect(() => { + if (defaultTheme) { + if (!theme) setTheme(defaultTheme.id); + if (!dashboardTheme) setDashboardTheme(defaultTheme.id); + } + }, [ dashboardTheme, defaultTheme, theme ]); + + // Update the current themes with values from user settings + const updateThemesFromSettings = useCallback(() => { + const userTheme = userSettings.theme(); + if (userTheme) setTheme(userTheme); + const userDashboardTheme = userSettings.dashboardTheme(); + if (userDashboardTheme) setDashboardTheme(userDashboardTheme); + }, []); + + const onUserSettingsChange = useCallback((_e, name?: string) => { + if (name && THEME_FIELD_NAMES.includes(name)) { + updateThemesFromSettings(); + } + }, [ updateThemesFromSettings ]); + + // Handle user settings changes + useEffect(() => { + Events.on(userSettings, 'change', onUserSettingsChange); + + return () => { + Events.off(userSettings, 'change', onUserSettingsChange); + }; + }, [ onUserSettingsChange ]); + + // Update the theme if the user changes + useEffect(() => { + updateThemesFromSettings(); + }, [ updateThemesFromSettings, user ]); + + return { + theme, + dashboardTheme + }; +} diff --git a/src/themes/UserThemeProvider.tsx b/src/themes/UserThemeProvider.tsx new file mode 100644 index 0000000000..effa6111c1 --- /dev/null +++ b/src/themes/UserThemeProvider.tsx @@ -0,0 +1,43 @@ +import { ThemeProvider } from '@mui/material'; +import React, { type FC, useState, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { DASHBOARD_APP_PATHS } from 'apps/dashboard/routes/routes'; +import { useUserTheme } from 'hooks/useUserTheme'; + +import { DEFAULT_THEME, getTheme } from './themes'; + +const isDashboardThemePage = (pathname: string) => [ + // NOTE: The metadata manager doesn't seem to use the dashboard theme + DASHBOARD_APP_PATHS.Dashboard, + DASHBOARD_APP_PATHS.PluginConfig +].some(path => pathname.startsWith(`/${path}`)); + +const UserThemeProvider: FC = ({ children }) => { + const [ isDashboard, setIsDashboard ] = useState(false); + const [ muiTheme, setMuiTheme ] = useState(DEFAULT_THEME); + + const location = useLocation(); + const { theme, dashboardTheme } = useUserTheme(); + + // Check if we are on a dashboard page when the path changes + useEffect(() => { + setIsDashboard(isDashboardThemePage(location.pathname)); + }, [ location.pathname ]); + + useEffect(() => { + if (isDashboard) { + setMuiTheme(getTheme(dashboardTheme)); + } else { + setMuiTheme(getTheme(theme)); + } + }, [ dashboardTheme, isDashboard, theme ]); + + return ( + + {children} + + ); +}; + +export default UserThemeProvider; diff --git a/src/themes/appletv/index.ts b/src/themes/appletv/index.ts new file mode 100644 index 0000000000..9acba52cfe --- /dev/null +++ b/src/themes/appletv/index.ts @@ -0,0 +1,27 @@ +import createTheme, { type ThemeOptions } from '@mui/material/styles/createTheme'; +import merge from 'lodash-es/merge'; + +import { DEFAULT_THEME_OPTIONS } from 'themes/defaults'; + +const themeOptions: ThemeOptions = { + palette: { + mode: 'light', + background: { + default: '#d5e9f2', + paper: '#fff' + } + }, + components: { + MuiAppBar: { + styleOverrides: { + colorPrimary: { + backgroundColor: '#bcbcbc' + } + } + } + } +}; + +const theme = createTheme(merge({}, DEFAULT_THEME_OPTIONS, themeOptions)); + +export default theme; diff --git a/src/themes/blueradiance/index.ts b/src/themes/blueradiance/index.ts new file mode 100644 index 0000000000..2b2634309b --- /dev/null +++ b/src/themes/blueradiance/index.ts @@ -0,0 +1,16 @@ +import createTheme, { type ThemeOptions } from '@mui/material/styles/createTheme'; +import merge from 'lodash-es/merge'; + +import { DEFAULT_THEME_OPTIONS } from 'themes/defaults'; + +const options: ThemeOptions = { + palette: { + background: { + paper: '#011432' + } + } +}; + +const theme = createTheme(merge({}, DEFAULT_THEME_OPTIONS, options)); + +export default theme; diff --git a/src/themes/dark/index.ts b/src/themes/dark/index.ts new file mode 100644 index 0000000000..71fcaa4256 --- /dev/null +++ b/src/themes/dark/index.ts @@ -0,0 +1,7 @@ +import createTheme from '@mui/material/styles/createTheme'; + +import { DEFAULT_THEME_OPTIONS } from 'themes/defaults'; + +const theme = createTheme(DEFAULT_THEME_OPTIONS); + +export default theme; diff --git a/src/themes/theme.ts b/src/themes/defaults.ts similarity index 87% rename from src/themes/theme.ts rename to src/themes/defaults.ts index 76c7f5bb8b..31f85d79c3 100644 --- a/src/themes/theme.ts +++ b/src/themes/defaults.ts @@ -1,19 +1,8 @@ -import { createTheme } from '@mui/material/styles'; - -declare module '@mui/material/styles' { - interface Palette { - starIcon: Palette['primary']; - } - - interface PaletteOptions { - starIcon?: PaletteOptions['primary']; - } -} +import type { ThemeOptions } from '@mui/material/styles/createTheme'; const LIST_ICON_WIDTH = 36; -/** The default Jellyfin app theme for mui */ -const theme = createTheme({ +export const DEFAULT_THEME_OPTIONS: ThemeOptions = { palette: { mode: 'dark', primary: { @@ -109,6 +98,4 @@ const theme = createTheme({ } } } -}); - -export default theme; +}; diff --git a/src/themes/light/index.ts b/src/themes/light/index.ts new file mode 100644 index 0000000000..f6a84e9bd6 --- /dev/null +++ b/src/themes/light/index.ts @@ -0,0 +1,30 @@ +import createTheme, { type ThemeOptions } from '@mui/material/styles/createTheme'; +import merge from 'lodash-es/merge'; + +import { DEFAULT_THEME_OPTIONS } from 'themes/defaults'; + +const options: ThemeOptions = { + palette: { + mode: 'light', + background: { + default: '#f2f2f2', + // NOTE: The original theme uses #303030 for the drawer and app bar but we would need the drawer to use + // dark mode for a color that dark to work properly which would require a separate ThemeProvider just for + // the drawer... which is not worth the trouble in my opinion + paper: '#e8e8e8' + } + }, + components: { + MuiAppBar: { + styleOverrides: { + colorPrimary: { + backgroundColor: '#e8e8e8' + } + } + } + } +}; + +const theme = createTheme(merge({}, DEFAULT_THEME_OPTIONS, options)); + +export default theme; diff --git a/src/themes/purplehaze/index.ts b/src/themes/purplehaze/index.ts new file mode 100644 index 0000000000..4071784228 --- /dev/null +++ b/src/themes/purplehaze/index.ts @@ -0,0 +1,22 @@ +import createTheme, { type ThemeOptions } from '@mui/material/styles/createTheme'; +import merge from 'lodash-es/merge'; + +import { DEFAULT_THEME_OPTIONS } from 'themes/defaults'; + +const options: ThemeOptions = { + palette: { + background: { + paper: '#000420' + }, + primary: { + main: '#48c3c8' + }, + secondary: { + main: '#ff77f1' + } + } +}; + +const theme = createTheme(merge({}, DEFAULT_THEME_OPTIONS, options)); + +export default theme; diff --git a/src/themes/themes.ts b/src/themes/themes.ts new file mode 100644 index 0000000000..6ab31786aa --- /dev/null +++ b/src/themes/themes.ts @@ -0,0 +1,48 @@ +import { type Theme } from '@mui/material/styles'; + +import appletv from './appletv'; +import blueradiance from './blueradiance'; +import dark from './dark'; +import light from './light'; +import purplehaze from './purplehaze'; +import wmc from './wmc'; + +declare module '@mui/material/styles' { + interface Palette { + starIcon: Palette['primary']; + } + + interface PaletteOptions { + starIcon?: PaletteOptions['primary']; + } +} + +const ALL_THEMES = { + appletv, + blueradiance, + dark, + light, + purplehaze, + wmc +}; + +/** The default theme if a user has not selected a preferred theme. */ +export const DEFAULT_THEME = dark; + +/** + * Gets a MUI Theme by its string id. Returns the default theme if no matching theme is found. + */ +export function getTheme(id?: string): Theme { + if (!id) { + console.info('[getTheme] no theme id; returning default theme'); + return DEFAULT_THEME; + } + + console.info('[getTheme] getting theme "%s"', id); + if (Object.keys(ALL_THEMES).includes(id)) { + return ALL_THEMES[id as keyof typeof ALL_THEMES]; + } + + console.warn('[getTheme] theme "%s" not found; returning default theme', id); + return DEFAULT_THEME; +} diff --git a/src/themes/wmc/index.ts b/src/themes/wmc/index.ts new file mode 100644 index 0000000000..105ac10d6c --- /dev/null +++ b/src/themes/wmc/index.ts @@ -0,0 +1,16 @@ +import createTheme, { type ThemeOptions } from '@mui/material/styles/createTheme'; +import merge from 'lodash-es/merge'; + +import { DEFAULT_THEME_OPTIONS } from 'themes/defaults'; + +const options: ThemeOptions = { + palette: { + background: { + paper: '#0c2450' + } + } +}; + +const theme = createTheme(merge({}, DEFAULT_THEME_OPTIONS, options)); + +export default theme;