mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Add support for user themes for mui components
This commit is contained in:
parent
3dcb42daac
commit
b5fda71a27
16 changed files with 298 additions and 62 deletions
|
@ -1,5 +1,4 @@
|
||||||
import loadable from '@loadable/component';
|
import loadable from '@loadable/component';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
|
||||||
import { History } from '@remix-run/router';
|
import { History } from '@remix-run/router';
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
@ -7,7 +6,6 @@ import React from 'react';
|
||||||
|
|
||||||
import { ApiProvider } from 'hooks/useApi';
|
import { ApiProvider } from 'hooks/useApi';
|
||||||
import { WebConfigProvider } from 'hooks/useWebConfig';
|
import { WebConfigProvider } from 'hooks/useWebConfig';
|
||||||
import theme from 'themes/theme';
|
|
||||||
import { queryClient } from 'utils/query/queryClient';
|
import { queryClient } from 'utils/query/queryClient';
|
||||||
|
|
||||||
const StableAppRouter = loadable(() => import('./apps/stable/AppRouter'));
|
const StableAppRouter = loadable(() => import('./apps/stable/AppRouter'));
|
||||||
|
@ -21,12 +19,10 @@ const RootApp = ({ history }: Readonly<{ history: History }>) => {
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ApiProvider>
|
<ApiProvider>
|
||||||
<WebConfigProvider>
|
<WebConfigProvider>
|
||||||
<ThemeProvider theme={theme}>
|
{isExperimentalLayout ?
|
||||||
{isExperimentalLayout ?
|
<RootAppRouter history={history} /> :
|
||||||
<RootAppRouter history={history} /> :
|
<StableAppRouter history={history} />
|
||||||
<StableAppRouter history={history} />
|
}
|
||||||
}
|
|
||||||
</ThemeProvider>
|
|
||||||
</WebConfigProvider>
|
</WebConfigProvider>
|
||||||
</ApiProvider>
|
</ApiProvider>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
|
|
@ -12,6 +12,7 @@ import AppHeader from 'components/AppHeader';
|
||||||
import Backdrop from 'components/Backdrop';
|
import Backdrop from 'components/Backdrop';
|
||||||
import { useLegacyRouterSync } from 'hooks/useLegacyRouterSync';
|
import { useLegacyRouterSync } from 'hooks/useLegacyRouterSync';
|
||||||
import { DASHBOARD_APP_ROUTES } from 'apps/dashboard/routes/routes';
|
import { DASHBOARD_APP_ROUTES } from 'apps/dashboard/routes/routes';
|
||||||
|
import UserThemeProvider from 'themes/UserThemeProvider';
|
||||||
|
|
||||||
const router = createHashRouter([
|
const router = createHashRouter([
|
||||||
{
|
{
|
||||||
|
@ -35,11 +36,11 @@ export default function RootAppRouter({ history }: Readonly<{ history: History}>
|
||||||
*/
|
*/
|
||||||
function RootAppLayout() {
|
function RootAppLayout() {
|
||||||
return (
|
return (
|
||||||
<>
|
<UserThemeProvider>
|
||||||
<Backdrop />
|
<Backdrop />
|
||||||
<AppHeader isHidden />
|
<AppHeader isHidden />
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</>
|
</UserThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,10 +12,11 @@ import React, { Fragment } from 'react';
|
||||||
|
|
||||||
import { appHost } from 'components/apphost';
|
import { appHost } from 'components/apphost';
|
||||||
import { useApi } from 'hooks/useApi';
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import { useThemes } from 'hooks/useThemes';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
import { DisplaySettingsValues } from './types';
|
import { DisplaySettingsValues } from './types';
|
||||||
import { useScreensavers } from './hooks/useScreensavers';
|
import { useScreensavers } from './hooks/useScreensavers';
|
||||||
import { useServerThemes } from './hooks/useServerThemes';
|
|
||||||
|
|
||||||
interface DisplayPreferencesProps {
|
interface DisplayPreferencesProps {
|
||||||
onChange: (event: SelectChangeEvent | React.SyntheticEvent) => void;
|
onChange: (event: SelectChangeEvent | React.SyntheticEvent) => void;
|
||||||
|
@ -25,7 +26,7 @@ interface DisplayPreferencesProps {
|
||||||
export function DisplayPreferences({ onChange, values }: Readonly<DisplayPreferencesProps>) {
|
export function DisplayPreferences({ onChange, values }: Readonly<DisplayPreferencesProps>) {
|
||||||
const { user } = useApi();
|
const { user } = useApi();
|
||||||
const { screensavers } = useScreensavers();
|
const { screensavers } = useScreensavers();
|
||||||
const { themes } = useServerThemes();
|
const { themes } = useThemes();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
|
|
|
@ -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<Theme[]>();
|
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -7,6 +7,7 @@ import { STABLE_APP_ROUTES } from './routes/routes';
|
||||||
import Backdrop from 'components/Backdrop';
|
import Backdrop from 'components/Backdrop';
|
||||||
import AppHeader from 'components/AppHeader';
|
import AppHeader from 'components/AppHeader';
|
||||||
import { DASHBOARD_APP_PATHS, DASHBOARD_APP_ROUTES } from 'apps/dashboard/routes/routes';
|
import { DASHBOARD_APP_PATHS, DASHBOARD_APP_ROUTES } from 'apps/dashboard/routes/routes';
|
||||||
|
import UserThemeProvider from 'themes/UserThemeProvider';
|
||||||
|
|
||||||
const router = createHashRouter([{
|
const router = createHashRouter([{
|
||||||
element: <StableAppLayout />,
|
element: <StableAppLayout />,
|
||||||
|
@ -32,11 +33,11 @@ function StableAppLayout() {
|
||||||
.some(path => location.pathname.startsWith(`/${path}`));
|
.some(path => location.pathname.startsWith(`/${path}`));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<UserThemeProvider>
|
||||||
<Backdrop />
|
<Backdrop />
|
||||||
<AppHeader isHidden={isNewLayoutPath} />
|
<AppHeader isHidden={isNewLayoutPath} />
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</>
|
</UserThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
16
src/hooks/useThemes.ts
Normal file
16
src/hooks/useThemes.ts
Normal file
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
57
src/hooks/useUserTheme.ts
Normal file
57
src/hooks/useUserTheme.ts
Normal file
|
@ -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<string>();
|
||||||
|
const [ dashboardTheme, setDashboardTheme ] = useState<string>();
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
43
src/themes/UserThemeProvider.tsx
Normal file
43
src/themes/UserThemeProvider.tsx
Normal file
|
@ -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 (
|
||||||
|
<ThemeProvider theme={muiTheme}>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserThemeProvider;
|
27
src/themes/appletv/index.ts
Normal file
27
src/themes/appletv/index.ts
Normal file
|
@ -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;
|
16
src/themes/blueradiance/index.ts
Normal file
16
src/themes/blueradiance/index.ts
Normal file
|
@ -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;
|
7
src/themes/dark/index.ts
Normal file
7
src/themes/dark/index.ts
Normal file
|
@ -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;
|
|
@ -1,19 +1,8 @@
|
||||||
import { createTheme } from '@mui/material/styles';
|
import type { ThemeOptions } from '@mui/material/styles/createTheme';
|
||||||
|
|
||||||
declare module '@mui/material/styles' {
|
|
||||||
interface Palette {
|
|
||||||
starIcon: Palette['primary'];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PaletteOptions {
|
|
||||||
starIcon?: PaletteOptions['primary'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const LIST_ICON_WIDTH = 36;
|
const LIST_ICON_WIDTH = 36;
|
||||||
|
|
||||||
/** The default Jellyfin app theme for mui */
|
export const DEFAULT_THEME_OPTIONS: ThemeOptions = {
|
||||||
const theme = createTheme({
|
|
||||||
palette: {
|
palette: {
|
||||||
mode: 'dark',
|
mode: 'dark',
|
||||||
primary: {
|
primary: {
|
||||||
|
@ -109,6 +98,4 @@ const theme = createTheme({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
export default theme;
|
|
30
src/themes/light/index.ts
Normal file
30
src/themes/light/index.ts
Normal file
|
@ -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;
|
22
src/themes/purplehaze/index.ts
Normal file
22
src/themes/purplehaze/index.ts
Normal file
|
@ -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;
|
48
src/themes/themes.ts
Normal file
48
src/themes/themes.ts
Normal file
|
@ -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;
|
||||||
|
}
|
16
src/themes/wmc/index.ts
Normal file
16
src/themes/wmc/index.ts
Normal file
|
@ -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;
|
Loading…
Add table
Add a link
Reference in a new issue