Repository: dermotduffy/frigate-hass-card
Pull request #1418 (2024-03-29 04:42:55)

Add support for configuration profiles
* Make configuration easier for #1322 


Patch:
diff --git a/src/card-controller/conditions-manager.ts b/src/card-controller/conditions-manager.ts
index d38bf35b..122a44b7 100644
--- a/src/card-controller/conditions-manager.ts
+++ b/src/card-controller/conditions-manager.ts
@@ -1,7 +1,7 @@
 import { CurrentUser } from '@dermotduffy/custom-card-helpers';
 import { HassEntities } from 'home-assistant-js-websocket';
 import merge from 'lodash-es/merge';
-import { copyConfig } from '../config-mgmt';
+import { copyConfig } from '../config/management';
 import {
   FrigateCardCondition,
   frigateConditionalSchema,
diff --git a/src/card-controller/config-manager.ts b/src/card-controller/config-manager.ts
index e597ad3a..a062944d 100644
--- a/src/card-controller/config-manager.ts
+++ b/src/card-controller/config-manager.ts
@@ -1,5 +1,5 @@
 import isEqual from 'lodash-es/isEqual';
-import { isConfigUpgradeable } from '../config-mgmt';
+import { isConfigUpgradeable } from '../config/management';
 import {
   CardWideConfig,
   FrigateCardConfig,
@@ -7,7 +7,7 @@ import {
   RawFrigateCardConfig
 } from '../config/types';
 import { localize } from '../localize/localize';
-import { setLowPerformanceProfile } from '../performance.js';
+import { setProfiles } from '../config/profiles';
 import { getParseErrorPaths } from '../utils/zod.js';
 import { getOverriddenConfig } from './conditions-manager';
 import { InitializationAspect } from './initialization-manager';
@@ -70,10 +70,7 @@ export class ConfigManager {
             : localize('error.invalid_configuration_no_hint')),
       );
     }
-    const config =
-      parseResult.data.performance.profile !== 'low'
-        ? parseResult.data
-        : setLowPerformanceProfile(inputConfig, parseResult.data);
+    const config = setProfiles(inputConfig, parseResult.data, parseResult.data.profiles);
 
     this._rawConfig = inputConfig;
     if (isEqual(this._config, config)) {
diff --git a/src/card-controller/style-manager.ts b/src/card-controller/style-manager.ts
index 1a5763de..02a95115 100644
--- a/src/card-controller/style-manager.ts
+++ b/src/card-controller/style-manager.ts
@@ -1,6 +1,5 @@
 import { StyleInfo } from 'lit/directives/style-map';
 import { FrigateCardConfig } from '../config/types';
-import { setPerformanceCSSStyles } from '../performance';
 import { aspectRatioToStyle, setOrRemoveAttribute } from '../utils/basic';
 import { View } from '../view/view';
 import { CardStyleAPI } from './types';
@@ -69,10 +68,22 @@ export class StyleManager {
   }
 
   public setPerformance(): void {
-    setPerformanceCSSStyles(
-      this._api.getCardElementManager().getElement(),
-      this._api.getConfigManager().getCardWideConfig()?.performance,
-    );
+    const STYLE_DISABLE_MAP = {
+      box_shadow: 'none',
+      border_radius: '0px',
+    };
+    const element = this._api.getCardElementManager().getElement();
+    const performance = this._api.getConfigManager().getCardWideConfig()?.performance;
+
+    const styles = performance?.style ?? {};
+    for (const configKey of Object.keys(styles)) {
+      const CSSKey = `--frigate-card-css-${configKey.replaceAll('_', '-')}`;
+      if (styles[configKey] === false) {
+        element.style.setProperty(CSSKey, STYLE_DISABLE_MAP[configKey]);
+      } else {
+        element.style.removeProperty(CSSKey);
+      }
+    }
   }
 
   protected _isAspectRatioEnforced(
diff --git a/src/components/live/live.ts b/src/components/live/live.ts
index a5f79ad0..4eacabfc 100644
--- a/src/components/live/live.ts
+++ b/src/components/live/live.ts
@@ -855,11 +855,7 @@ export class FrigateCardLiveProvider
       ) {
         return 'webrtc-card';
       } else if (this.cameraConfig?.camera_entity) {
-        if (this.cardWideConfig?.performance?.profile === 'low') {
-          return 'image';
-        } else {
-          return 'ha';
-        }
+        return 'ha';
       } else if (this.cameraConfig?.frigate.camera_name) {
         return 'jsmpeg';
       }
diff --git a/src/config-mgmt.ts b/src/config/management.ts
similarity index 98%
rename from src/config-mgmt.ts
rename to src/config/management.ts
index 28f379ab..146531f2 100644
--- a/src/config-mgmt.ts
+++ b/src/config/management.ts
@@ -7,7 +7,7 @@ import {
   FrigateCardCondition,
   RawFrigateCardConfig,
   RawFrigateCardConfigArray,
-} from './config/types';
+} from './types';
 import {
   CONF_AUTOMATIONS,
   CONF_CAMERAS,
@@ -22,14 +22,15 @@ import {
   CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
   CONF_MENU_BUTTONS_CAMERA_UI,
   CONF_OVERRIDES,
+  CONF_PROFILES,
   CONF_TIMELINE_EVENTS_MEDIA_TYPE,
   CONF_VIEW_INTERACTION_SECONDS,
   CONF_VIEW_TRIGGERS,
   CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
   CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
   CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
-} from './const';
-import { arrayify } from './utils/basic';
+} from '../const';
+import { arrayify } from '../utils/basic';
 
 // *************************************************************************
 //                  General Config Management Functions
@@ -684,4 +685,8 @@ const UPGRADES = [
       transform: (val) => (val === true ? { disable_except: 'substream' } : null),
     }),
   ),
+  upgradeMoveToWithOverrides('performance.profile', CONF_PROFILES, {
+    // Delete the value if it's set to the default.
+    transform: (val) => (val === 'low' ? ['low-performance'] : null),
+  }),
 ];
diff --git a/src/config/profiles/index.ts b/src/config/profiles/index.ts
new file mode 100644
index 00000000..61460daf
--- /dev/null
+++ b/src/config/profiles/index.ts
@@ -0,0 +1,51 @@
+import { getConfigValue, setConfigValue } from '../management.js';
+import {
+  ProfileType,
+  RawFrigateCardConfig,
+  frigateCardConfigSchema,
+} from '../types.js';
+import { deepRemoveDefaults } from '../../utils/zod.js';
+import { LOW_PERFORMANCE_PROFILE } from './low-performance.js';
+import { SCRUBBING_PROFILE } from './scrubbing.js';
+
+const PROFILES = {
+  'low-performance': LOW_PERFORMANCE_PROFILE,
+  'scrubbing': SCRUBBING_PROFILE,
+};
+
+/**
+ * Set a profile. Sets flags as defined in the relevant profile unless they are
+ * explicitly overriden in the configuration.
+ * @param inputConfig The raw unparsed input configuration.
+ * @param outputConfig The output config to write to.
+ * @returns A changed (in-place) parsed input configuration.
+ */
+export const setProfiles = <T extends RawFrigateCardConfig>(
+  inputConfig: RawFrigateCardConfig,
+  outputConfig: T,
+  profiles?: ProfileType[],
+): T => {
+  const defaultLessParseResult = deepRemoveDefaults(frigateCardConfigSchema).safeParse(
+    inputConfig,
+  );
+  if (!defaultLessParseResult.success) {
+    return outputConfig;
+  }
+  const defaultLessConfig = defaultLessParseResult.data;
+
+  const setIfNotSpecified = (key: string, value: unknown) => {
+    if (getConfigValue(defaultLessConfig, key) === undefined) {
+      setConfigValue(outputConfig, key, value);
+    }
+  };
+
+  for (const profile of profiles ?? []) {
+    if (profile in PROFILES) {
+      Object.entries(PROFILES[profile]).forEach(([k, v]: [string, unknown]) =>
+        setIfNotSpecified(k, v),
+      );
+    }
+  }
+
+  return outputConfig;
+};
diff --git a/src/performance.ts b/src/config/profiles/low-performance.ts
similarity index 71%
rename from src/performance.ts
rename to src/config/profiles/low-performance.ts
index 487de6fc..570c8771 100644
--- a/src/performance.ts
+++ b/src/config/profiles/low-performance.ts
@@ -1,11 +1,6 @@
-import { getConfigValue, setConfigValue } from './config-mgmt.js';
-import {
-  PerformanceConfig,
-  RawFrigateCardConfig,
-  frigateCardConfigSchema,
-} from './config/types.js';
 import {
   CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS,
+  CONF_CAMERAS_GLOBAL_LIVE_PROVIDER,
   CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY,
   CONF_LIVE_AUTO_MUTE,
   CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
@@ -51,13 +46,9 @@ import {
   CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
   CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
   CONF_TIMELINE_SHOW_RECORDINGS,
-} from './const.js';
-import { deepRemoveDefaults } from './utils/zod.js';
+} from '../../const.js';
 
-// Caution: These values are applied after parsing (since we cannot know the
-// performance profile until afterwards), so there is no validation on these
-// defaults.
-const LOW_PROFILE_DEFAULTS = {
+export const LOW_PERFORMANCE_PROFILE = {
   // Disable thumbnail carousels.
   [CONF_LIVE_CONTROLS_THUMBNAILS_MODE]: 'none' as const,
   [CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE]: 'none' as const,
@@ -138,68 +129,9 @@ const LOW_PROFILE_DEFAULTS = {
   [CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP]: false,
 
   [CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY]: false,
+  [CONF_CAMERAS_GLOBAL_LIVE_PROVIDER]: 'image',
 
   // Refresh the live camera image every 10 seconds (same as stock Home
   // Assistant Picture Glance).
   [CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS]: 10,
 };
-
-/**
- * Set low performance profile mode. Sets flags as defined in
- * LOW_PROFILE_DEFAULTS unless they are explicitly overriden in the
- * configuration.
- * @param inputConfig The raw unparsed input configuration.
- * @param outputConfig The output config to write to.
- * @returns A changed (in-place) parsed input configuration.
- */
-export const setLowPerformanceProfile = <T extends RawFrigateCardConfig>(
-  inputConfig: RawFrigateCardConfig,
-  outputConfig: T,
-): T => {
-  const setIfNotSpecified = (
-    defaultLessConfig: RawFrigateCardConfig,
-    outputConfig: T,
-    key: string,
-    value: unknown,
-  ) => {
-    if (getConfigValue(defaultLessConfig, key) === undefined) {
-      setConfigValue(outputConfig, key, value);
-    }
-  };
-
-  const defaultLessParseResult = deepRemoveDefaults(frigateCardConfigSchema).safeParse(
-    inputConfig,
-  );
-  if (defaultLessParseResult.success) {
-    const defaultLessConfig = defaultLessParseResult.data;
-    Object.entries(LOW_PROFILE_DEFAULTS).forEach(([k, v]: [string, unknown]) =>
-      setIfNotSpecified(defaultLessConfig, outputConfig, k, v),
-    );
-  }
-  return outputConfig;
-};
-
-const STYLE_DISABLE_MAP = {
-  box_shadow: 'none',
-  border_radius: '0px',
-};
-
-/**
- * Set card-wide CSS variables for performance.
- * @param element The element to set the variables on.
- * @param performance The performance configuration.
- */
-export const setPerformanceCSSStyles = (
-  element: HTMLElement,
-  performance?: PerformanceConfig,
-): void => {
-  const styles = performance?.style ?? {};
-  for (const configKey of Object.keys(styles)) {
-    const CSSKey = `--frigate-card-css-${configKey.replaceAll('_', '-')}`;
-    if (styles[configKey] === false) {
-      element.style.setProperty(CSSKey, STYLE_DISABLE_MAP[configKey]);
-    } else {
-      element.style.removeProperty(CSSKey);
-    }
-  }
-};
diff --git a/src/config/profiles/scrubbing.ts b/src/config/profiles/scrubbing.ts
new file mode 100644
index 00000000..b66fe0f7
--- /dev/null
+++ b/src/config/profiles/scrubbing.ts
@@ -0,0 +1,18 @@
+import {
+  CONF_LIVE_CONTROLS_TIMELINE_MODE,
+  CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE,
+  CONF_LIVE_CONTROLS_TIMELINE_STYLE,
+  CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
+  CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE,
+  CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
+} from '../../const.js';
+
+export const SCRUBBING_PROFILE = {
+  [CONF_LIVE_CONTROLS_TIMELINE_MODE]: 'below' as const,
+  [CONF_LIVE_CONTROLS_TIMELINE_STYLE]: 'ribbon' as const,
+  [CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE]: 'seek' as const,
+
+  [CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE]: 'below' as const,
+  [CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE]: 'ribbon' as const,
+  [CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE]: 'seek' as const,
+};
diff --git a/src/config/types.ts b/src/config/types.ts
index 67508e46..d3ca5191 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -1623,7 +1623,6 @@ export type Automations = z.infer<typeof automationsSchema>;
 // *************************************************************************
 
 const performanceConfigDefault = {
-  profile: 'high' as const,
   features: {
     animated_progress_indicator: true,
     media_chunk_size: MEDIA_CHUNK_SIZE_DEFAULT,
@@ -1636,7 +1635,6 @@ const performanceConfigDefault = {
 
 export const performanceConfigSchema = z
   .object({
-    profile: z.enum(['low', 'high']).default(performanceConfigDefault.profile),
     features: z
       .object({
         animated_progress_indicator: z
@@ -1679,6 +1677,13 @@ export interface CardWideConfig {
   debug?: DebugConfig;
 }
 
+// *************************************************************************
+//                      *** Profile Configuration ***
+// *************************************************************************
+const PROFILES = ['low-performance', 'scrubbing'] as const;
+export type ProfileType = (typeof PROFILES)[number];
+export const profilesSchema = z.enum(PROFILES).array().optional();
+
 // *************************************************************************
 //                      *** Card Configuration ***
 // *************************************************************************
@@ -1705,6 +1710,8 @@ export const frigateCardConfigSchema = z.object({
   debug: debugConfigSchema,
   automations: automationsSchema,
 
+  profiles: profilesSchema,
+
   // Configuration overrides.
   overrides: overridesSchema,
 
diff --git a/src/const.ts b/src/const.ts
index 7471da29..6a497438 100644
--- a/src/const.ts
+++ b/src/const.ts
@@ -72,6 +72,8 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS =
 
 const CONF_CAMERAS_GLOBAL = 'cameras_global' as const;
 export const CONF_CAMERAS_GLOBAL_IMAGE = `${CONF_CAMERAS_GLOBAL}.image` as const;
+export const CONF_CAMERAS_GLOBAL_LIVE_PROVIDER =
+  `${CONF_CAMERAS_GLOBAL}.live_provider` as const;
 export const CONF_CAMERAS_GLOBAL_JSMPEG = `${CONF_CAMERAS_GLOBAL}.jsmpeg` as const;
 export const CONF_CAMERAS_GLOBAL_WEBRTC_CARD =
   `${CONF_CAMERAS_GLOBAL}.webrtc_card` as const;
@@ -313,6 +315,8 @@ export const CONF_PERFORMANCE_PROFILE = `${CONF_PERFORMANCE}.profile`;
 export const CONF_PERFORMANCE_STYLE_BOX_SHADOW = `${CONF_PERFORMANCE}.style.box_shadow`;
 export const CONF_PERFORMANCE_STYLE_BORDER_RADIUS = `${CONF_PERFORMANCE}.style.border_radius`;
 
+export const CONF_PROFILES = 'profiles' as const;
+
 // Taken from https://github.dev/home-assistant/frontend/blob/b5861869e39290fd2e15737e89571dfc543b3ad3/src/data/media-player.ts#L93
 export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
 
diff --git a/src/editor.ts b/src/editor.ts
index b9865192..9502d69f 100644
--- a/src/editor.ts
+++ b/src/editor.ts
@@ -19,21 +19,24 @@ import {
   isConfigUpgradeable,
   setConfigValue,
   upgradeConfig,
-} from './config-mgmt.js';
+} from './config/management.js';
+import { setProfiles } from './config/profiles/index.js';
 import {
   BUTTON_SIZE_MIN,
   FrigateCardConfig,
   frigateCardConfigDefaults,
   FRIGATE_MENU_PRIORITY_MAX,
+  profilesSchema,
   RawFrigateCardConfig,
   RawFrigateCardConfigArray,
   THUMBNAIL_WIDTH_MAX,
   THUMBNAIL_WIDTH_MIN,
 } from './config/types.js';
 import {
+  CONF_CAMERAS,
   CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
-  CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT,
   CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE,
+  CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT,
   CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH,
   CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH,
   CONF_CAMERAS_ARRAY_CAST_METHOD,
@@ -67,9 +70,8 @@ import {
   CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
   CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
   CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
-  CONF_CAMERAS,
-  CONF_DIMENSIONS_ASPECT_RATIO_MODE,
   CONF_DIMENSIONS_ASPECT_RATIO,
+  CONF_DIMENSIONS_ASPECT_RATIO_MODE,
   CONF_DIMENSIONS_MAX_HEIGHT,
   CONF_DIMENSIONS_MIN_HEIGHT,
   CONF_IMAGE_MODE,
@@ -158,8 +160,8 @@ import {
   CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
   CONF_MEDIA_VIEWER_ZOOMABLE,
   CONF_MENU_ALIGNMENT,
-  CONF_MENU_BUTTON_SIZE,
   CONF_MENU_BUTTONS,
+  CONF_MENU_BUTTON_SIZE,
   CONF_MENU_POSITION,
   CONF_MENU_STYLE,
   CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
@@ -167,6 +169,7 @@ import {
   CONF_PERFORMANCE_PROFILE,
   CONF_PERFORMANCE_STYLE_BORDER_RADIUS,
   CONF_PERFORMANCE_STYLE_BOX_SHADOW,
+  CONF_PROFILES,
   CONF_TIMELINE_CLUSTERING_THRESHOLD,
   CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
   CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
@@ -183,21 +186,20 @@ import {
   CONF_VIEW_DEFAULT,
   CONF_VIEW_INTERACTION_SECONDS,
   CONF_VIEW_RESET_AFTER_INTERACTION,
+  CONF_VIEW_TRIGGERS,
+  CONF_VIEW_TRIGGERS_ACTIONS,
   CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
   CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
   CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
-  CONF_VIEW_TRIGGERS_ACTIONS,
   CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
   CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
   CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS,
-  CONF_VIEW_TRIGGERS,
   CONF_VIEW_UPDATE_CYCLE_CAMERA,
   CONF_VIEW_UPDATE_FORCE,
   CONF_VIEW_UPDATE_SECONDS,
   MEDIA_CHUNK_SIZE_MAX,
 } from './const.js';
 import { localize } from './localize/localize.js';
-import { setLowPerformanceProfile } from './performance.js';
 import frigate_card_editor_style from './scss/editor.scss';
 import { arrayMove, prettifyTitle } from './utils/basic.js';
 import { getCameraID } from './utils/camera.js';
@@ -315,6 +317,11 @@ const options: EditorOptions = {
     name: localize('editor.performance'),
     secondary: localize('editor.performance_secondary'),
   },
+  profiles: {
+    icon: 'folder-wrench-outline',
+    name: localize('editor.profiles'),
+    secondary: localize('editor.profiles_secondary'),
+  },
   overrides: {
     icon: 'file-replace',
     name: localize('editor.overrides'),
@@ -589,10 +596,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
     { value: 'below', label: localize('config.common.controls.timeline.modes.below') },
   ];
 
-  protected _performanceProfiles: EditorSelectOption[] = [
+  protected _profiles: EditorSelectOption[] = [
     { value: '', label: '' },
-    { value: 'low', label: localize('config.performance.profiles.low') },
-    { value: 'high', label: localize('config.performance.profiles.high') },
+    { value: 'low-performance', label: localize('config.profiles.low-performance') },
+    { value: 'scrubbing', label: localize('config.profiles.scrubbing') },
   ];
 
   protected _go2rtcModes: EditorSelectOption[] = [
@@ -788,25 +795,20 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
   ];
 
   public setConfig(config: RawFrigateCardConfig): void {
-    // Note: This does not use Zod to parse the configuration, so it may be
+    // Note: This does not use Zod to parse the full configuration, so it may be
     // partially or completely invalid. It's more useful to have a partially
     // valid configuration here, to allow the user to fix the broken parts. As
     // such, RawFrigateCardConfig is used as the type.
     this._config = config;
     this._configUpgradeable = isConfigUpgradeable(config);
 
-    let unvalidatedProfile: string | null = null;
-    try {
-      // this._config may not be a valid FrigateCardConfig as it has not been
-      // parsed. Attempt to pull out the performance profile.
-      unvalidatedProfile = (this._config as FrigateCardConfig).performance?.profile;
-    } catch (_) {}
+    const profiles = profilesSchema.safeParse(
+      (this._config as FrigateCardConfig).profiles,
+    );
 
-    if (unvalidatedProfile === 'high' || unvalidatedProfile === 'low') {
+    if (profiles.success) {
       const defaults = copyConfig(frigateCardConfigDefaults);
-      if (unvalidatedProfile === 'low') {
-        setLowPerformanceProfile(this._config, defaults);
-      }
+      setProfiles(this._config, defaults, profiles.data);
       this._defaults = defaults;
     }
   }
@@ -2130,6 +2132,15 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
               </div>
             `
           : ''}
+        ${this._renderOptionSetHeader('profiles')}
+        ${this._expandedMenus[MENU_OPTIONS] === 'profiles'
+          ? html` <div class="values">
+              ${this._renderOptionSelector(CONF_PROFILES, this._profiles, {
+                multiple: true,
+                label: localize('config.profiles.editor_label'),
+              })}
+            </div>`
+          : ''}
         ${this._renderOptionSetHeader('view')}
         ${this._expandedMenus[MENU_OPTIONS] === 'view'
           ? html`
@@ -2556,10 +2567,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
               ${getConfigValue(this._config, CONF_PERFORMANCE_PROFILE) === 'low'
                 ? this._renderInfo(localize('config.performance.warning'))
                 : html``}
-              ${this._renderOptionSelector(
-                CONF_PERFORMANCE_PROFILE,
-                this._performanceProfiles,
-              )}
               ${this._putInSubmenu(
                 MENU_PERFORMANCE_FEATURES,
                 true,
diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json
index 20f72390..a822bf33 100644
--- a/src/localize/languages/ca.json
+++ b/src/localize/languages/ca.json
@@ -401,11 +401,6 @@
         "editor_label": "Opcions de característiques",
         "media_chunk_size": "Mida del fragment multimèdia"
       },
-      "profile": "Perfil de rendiment",
-      "profiles": {
-        "high": "Alt rendiment",
-        "low": "Baix rendiment"
-      },
       "style": {
         "border_radius": "Corbes",
         "box_shadow": "Ombres",
@@ -413,6 +408,11 @@
       },
       "warning": "Aquesta targeta està en mode de perfil baix, de manera que els valors predeterminats han canviat per optimitzar el rendiment"
     },
+    "profiles": {
+      "editor_label": "",
+      "low-performance": "",
+      "scrubbing": ""
+    },
     "view": {
       "camera_select": "Visualitza les càmeres seleccionades recentment",
       "dark_mode": "Mode fosc",
@@ -497,6 +497,8 @@
     "overrides_secondary": "S'han detectat substitucions de configuració dinàmica",
     "performance": "Rendiment",
     "performance_secondary": "Opcions de rendiment de la targeta",
+    "profiles": "",
+    "profiles_secondary": "",
     "timeline": "Cronologia",
     "timeline_secondary": "Opcions de la cronologia d'esdeveniments",
     "upgrade": "Upgrade",
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json
index d3bb0d23..f0c5df92 100644
--- a/src/localize/languages/en.json
+++ b/src/localize/languages/en.json
@@ -401,11 +401,6 @@
         "editor_label": "Feature Options",
         "media_chunk_size": "Media chunk size"
       },
-      "profile": "Performance profile",
-      "profiles": {
-        "high": "High/full performance",
-        "low": "Low performance"
-      },
       "style": {
         "border_radius": "Curves",
         "box_shadow": "Shadows",
@@ -413,6 +408,11 @@
       },
       "warning": "This card is in low profile mode so defaults have changed to optimize performance"
     },
+    "profiles": {
+      "editor_label": "Configuration profiles",
+      "low-performance": "Low performance",
+      "scrubbing": "Video scrubbing"
+    },
     "view": {
       "camera_select": "View for newly selected cameras",
       "dark_mode": "Dark mode",
@@ -497,6 +497,8 @@
     "overrides_secondary": "Dynamic configuration overrides detected",
     "performance": "Performance",
     "performance_secondary": "Card performance options",
+    "profiles": "Configuration profiles",
+    "profiles_secondary": "Choose pre-configured sets of defaults",
     "timeline": "Timeline",
     "timeline_secondary": "Event timeline options",
     "upgrade": "Upgrade",
diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json
index 041ed7eb..fdc1107b 100644
--- a/src/localize/languages/fr.json
+++ b/src/localize/languages/fr.json
@@ -401,11 +401,6 @@
         "editor_label": "Options de fonctionnalités",
         "media_chunk_size": "Taille du morceau de média"
       },
-      "profile": "Profil de performances",
-      "profiles": {
-        "high": "Performances élevées",
-        "low": "Performances faibles"
-      },
       "style": {
         "border_radius": "Courbes",
         "box_shadow": "Ombres",
@@ -413,6 +408,11 @@
       },
       "warning": "Cette carte est en mode profil bas, les paramètres par défaut ont donc été modifiés pour optimiser les performances."
     },
+    "profiles": {
+      "editor_label": "",
+      "low-performance": "",
+      "scrubbing": ""
+    },
     "view": {
       "camera_select": "Afficher les caméras nouvellement sélectionnées",
       "dark_mode": "Mode sombre",
@@ -497,6 +497,8 @@
     "overrides_secondary": "Remplacements de configuration dynamique détectés",
     "performance": "Performance",
     "performance_secondary": "Options de performances de la carte",
+    "profiles": "",
+    "profiles_secondary": "",
     "timeline": "Chronologie",
     "timeline_secondary": "Options de chronologie des événements",
     "upgrade": "Mise à niveau",
diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json
index 7acdea38..e515dc57 100644
--- a/src/localize/languages/it.json
+++ b/src/localize/languages/it.json
@@ -401,11 +401,6 @@
         "editor_label": "Opzioni funzionalità",
         "media_chunk_size": "Dimensione del blocco multimediale"
       },
-      "profile": "Profilo delle prestazioni",
-      "profiles": {
-        "high": "Prestazioni alte",
-        "low": "Prestazioni basse"
-      },
       "style": {
         "border_radius": "Curve",
         "box_shadow": "Ombre",
@@ -413,6 +408,11 @@
       },
       "warning": "Questa scheda è in modalità basso profilo, quindi le impostazioni predefinite sono state modificate per ottimizzare le prestazioni"
     },
+    "profiles": {
+      "editor_label": "",
+      "low-performance": "",
+      "scrubbing": ""
+    },
     "view": {
       "camera_select": "Visualizza per le telecamere appena selezionate",
       "dark_mode": "Tema scuro",
@@ -497,6 +497,8 @@
     "overrides_secondary": "Rilevate sovrascritture della configurazione dinamica",
     "performance": "",
     "performance_secondary": "",
+    "profiles": "",
+    "profiles_secondary": "",
     "timeline": "Timeline",
     "timeline_secondary": "Opzioni della timeline degli eventi",
     "upgrade": "Aggiornamento",
diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json
index 8e566333..ad8fe495 100644
--- a/src/localize/languages/pt-BR.json
+++ b/src/localize/languages/pt-BR.json
@@ -401,11 +401,6 @@
         "editor_label": "Opções de recursos",
         "media_chunk_size": "Tamanho do bloco de mídia"
       },
-      "profile": "Perfil de desempenho",
-      "profiles": {
-        "high": "Alto desempenho/completo",
-        "low": "Baixo desempenho"
-      },
       "style": {
         "border_radius": "Curvas",
         "box_shadow": "Sombras",
@@ -413,6 +408,11 @@
       },
       "warning": "Este cartão está no modo de baixo desempenho, então os padrões foram alterados para otimizar o desempenho"
     },
+    "profiles": {
+      "editor_label": "",
+      "low-performance": "",
+      "scrubbing": ""
+    },
     "view": {
       "camera_select": "Visualização de câmeras recém-selecionadas",
       "dark_mode": "Modo escuro",
@@ -497,6 +497,8 @@
     "overrides_secondary": "Substituições de configuração dinâmica detectadas",
     "performance": "Desempenho",
     "performance_secondary": "Opções de desempenho do cartão",
+    "profiles": "",
+    "profiles_secondary": "",
     "timeline": "Linha do tempo",
     "timeline_secondary": "Opções do evento da linha do tempo",
     "upgrade": "Upgrade",
diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json
index e153b660..fb8f531c 100644
--- a/src/localize/languages/pt-PT.json
+++ b/src/localize/languages/pt-PT.json
@@ -401,11 +401,6 @@
         "editor_label": "Editor de etiquetas",
         "media_chunk_size": "Tamanho do ficheiro"
       },
-      "profile": "Perfil",
-      "profiles": {
-        "high": "Alto",
-        "low": "Baixo"
-      },
       "style": {
         "border_radius": "Tamanho do bordo",
         "box_shadow": "Caixa de Fundo",
@@ -413,6 +408,11 @@
       },
       "warning": "Avisos"
     },
+    "profiles": {
+      "editor_label": "",
+      "low-performance": "",
+      "scrubbing": ""
+    },
     "view": {
       "camera_select": "Visualização de câmeras recém-selecionadas",
       "dark_mode": "Modo escuro",
@@ -497,6 +497,8 @@
     "overrides_secondary": "Substituições de configuração dinâmica detectadas",
     "performance": "",
     "performance_secondary": "",
+    "profiles": "",
+    "profiles_secondary": "",
     "timeline": "Linha do tempo",
     "timeline_secondary": "Opções do evento da linha do tempo",
     "upgrade": "Actualização",
diff --git a/vite.config.ts b/vite.config.ts
index afc7ade6..0aaea85e 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -18,8 +18,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [
   'components-lib/menu-controller.ts',
   'components-lib/ptz-controller.ts',
   'components-lib/zoom-controller.ts',
-  'config-mgmt.ts',
-  'config/types.ts',
+  'config/**/*.ts',
   'const.ts',
   'types.ts',
   'utils/action.ts',


Test patch:
diff --git a/tests/card-controller/config-manager.test.ts b/tests/card-controller/config-manager.test.ts
index 6d063b27..7bbb2f2b 100644
--- a/tests/card-controller/config-manager.test.ts
+++ b/tests/card-controller/config-manager.test.ts
@@ -78,7 +78,7 @@ describe('ConfigManager', () => {
 
     manager.setConfig(config);
 
-    expect(manager.hasConfig()).toBeTruthy()
+    expect(manager.hasConfig()).toBeTruthy();
     expect(manager.getRawConfig()).toBe(config);
 
     // Verify at least the camera is set.
@@ -102,12 +102,12 @@ describe('ConfigManager', () => {
     expect(api.getCardElementManager().update).toBeCalled();
   });
 
-  it('should apply low performance defaults', () => {
+  it('should apply profiles', () => {
     const manager = new ConfigManager(createCardAPI());
     const config = {
       type: 'custom:frigate-card',
       cameras: [{ camera_entity: 'camera.office' }],
-      performance: { profile: 'low' },
+      profiles: ['low-performance'],
     };
 
     manager.setConfig(config);
@@ -143,7 +143,9 @@ describe('ConfigManager', () => {
         logging: true,
       },
       performance: {
-        profile: 'low',
+        style: {
+          box_shadow: false,
+        },
       },
     };
 
@@ -155,12 +157,11 @@ describe('ConfigManager', () => {
       },
       performance: {
         features: {
-          animated_progress_indicator: false,
-          media_chunk_size: 10,
+          animated_progress_indicator: true,
+          media_chunk_size: 50,
         },
-        profile: 'low',
         style: {
-          border_radius: false,
+          border_radius: true,
           box_shadow: false,
         },
       },
@@ -257,8 +258,8 @@ describe('ConfigManager', () => {
       const config_2 = {
         ...config_1,
         cameras_global: {
-          live_provider: 'jsmpeg'
-        }
+          live_provider: 'jsmpeg',
+        },
       };
       vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2));
       manager.computeOverrideConfig();
@@ -276,9 +277,9 @@ describe('ConfigManager', () => {
         cameras: [{ camera_entity: 'camera.office' }],
         live: {
           microphone: {
-            always_connected: false
-          }
-        }
+            always_connected: false,
+          },
+        },
       };
       vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1));
 
@@ -289,9 +290,9 @@ describe('ConfigManager', () => {
         ...config_1,
         live: {
           microphone: {
-            always_connected: true
-          }
-        }
+            always_connected: true,
+          },
+        },
       };
       vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2));
       manager.computeOverrideConfig();
diff --git a/tests/card-controller/style-manager.test.ts b/tests/card-controller/style-manager.test.ts
index 23a58bae..c2920b70 100644
--- a/tests/card-controller/style-manager.test.ts
+++ b/tests/card-controller/style-manager.test.ts
@@ -1,11 +1,8 @@
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 import { FrigateCardView } from '../../src/config/types';
-import { setPerformanceCSSStyles } from '../../src/performance';
 import { StyleManager } from '../../src/card-controller/style-manager';
 import { createCardAPI, createConfig, createHASS, createView } from '../test-utils';
 
-vi.mock('../../src/performance');
-
 // @vitest-environment jsdom
 describe('StyleManager', () => {
   beforeEach(() => {
@@ -226,19 +223,49 @@ describe('StyleManager', () => {
     });
   });
 
-  it('setPerformance', () => {
-    const api = createCardAPI();
-    const element = document.createElement('div');
-    vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
-    const config = createConfig();
-    vi.mocked(api.getConfigManager().getCardWideConfig).mockReturnValue({
-      performance: config.performance,
+  describe('setPerformance', () => {
+    it('no styles set', () => {
+      const api = createCardAPI();
+      const element = document.createElement('div');
+      vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
+      vi.mocked(api.getConfigManager().getCardWideConfig).mockReturnValue({});
+      const manager = new StyleManager(api);
+
+      manager.setPerformance();
+
+      expect(
+        element.style.getPropertyValue('--frigate-card-css-box-shadow'),
+      ).toBeFalsy();
+      expect(
+        element.style.getPropertyValue('--frigate-card-css-border-radius'),
+      ).toBeFalsy();
     });
-    const manager = new StyleManager(api);
 
-    manager.setPerformance();
+    it('valid styles set', () => {
+      const api = createCardAPI();
+      const element = document.createElement('div');
+      vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
+      vi.mocked(api.getConfigManager().getCardWideConfig).mockReturnValue(
+        createConfig({
+          performance: {
+            style: {
+              box_shadow: false,
+              border_radius: true,
+            },
+          },
+        }),
+      );
+      const manager = new StyleManager(api);
+
+      manager.setPerformance();
 
-    expect(setPerformanceCSSStyles).toBeCalledWith(element, config.performance);
+      expect(element.style.getPropertyValue('--frigate-card-css-box-shadow')).toEqual(
+        'none',
+      );
+      expect(
+        element.style.getPropertyValue('--frigate-card-css-border-radius'),
+      ).toBeFalsy();
+    });
   });
 
   describe('getAspectRatioStyle', () => {
diff --git a/tests/config-mgmt.test.ts b/tests/config/management.test.ts
similarity index 98%
rename from tests/config-mgmt.test.ts
rename to tests/config/management.test.ts
index 3961f0e0..e12e60e4 100644
--- a/tests/config-mgmt.test.ts
+++ b/tests/config/management.test.ts
@@ -16,8 +16,8 @@ import {
   upgradeMoveToWithOverrides,
   upgradeObjectRecursively,
   upgradeWithOverrides,
-} from '../src/config-mgmt';
-import { RawFrigateCardConfig } from '../src/config/types';
+} from '../../src/config/management';
+import { RawFrigateCardConfig } from '../../src/config/types';
 
 describe('general functions', () => {
   it('should set value', () => {
@@ -2459,6 +2459,43 @@ describe('should handle version specific upgrades', () => {
           ],
         });
       });
+
+      describe('from performance profile to generic profile', () => {
+        it('low performance', () => {
+          const config = {
+            type: 'custom:frigate-card',
+            cameras: [],
+            performance: {
+              profile: 'low',
+            },
+          };
+
+          expect(upgradeConfig(config)).toBeTruthy();
+          expect(config).toEqual({
+            type: 'custom:frigate-card',
+            cameras: [],
+            profiles: ['low-performance'],
+            performance: {},
+          });
+        });
+
+        it('high performance', () => {
+          const config = {
+            type: 'custom:frigate-card',
+            cameras: [],
+            performance: {
+              profile: 'high',
+            },
+          };
+
+          expect(upgradeConfig(config)).toBeTruthy();
+          expect(config).toEqual({
+            type: 'custom:frigate-card',
+            cameras: [],
+            performance: {},
+          });
+        });
+      });
     });
   });
 });
diff --git a/tests/config/profiles/index.test.ts b/tests/config/profiles/index.test.ts
new file mode 100644
index 00000000..20fd2ad5
--- /dev/null
+++ b/tests/config/profiles/index.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest';
+import { ProfileType } from '../../../src/config/types';
+import { setProfiles } from '../../../src/config/profiles';
+import { createConfig } from '../../test-utils';
+
+describe('setProfiles', () => {
+  it('should handle failed parse', () => {
+    expect(setProfiles({ cameras: 'not_an_array' }, {})).toEqual({});
+  });
+
+  it('should handle no profiles', () => {
+    const input = createConfig();
+    const output = createConfig();
+
+    expect(setProfiles(input, output)).toEqual(input);
+  });
+
+  it('should handle invalid profiles', () => {
+    const input = createConfig();
+    const output = createConfig();
+
+    expect(setProfiles(input, output, ['bogus' as ProfileType])).toEqual(input);
+  });
+
+  it('should handle profiles', () => {
+    const input = {
+      type: 'frigate-hass-card',
+      cameras: [],
+      live: {
+        controls: {
+          timeline: {
+            // This will not be overridden.
+            style: 'stack',
+          },
+        },
+      },
+    };
+    expect(setProfiles(input, {}, ['scrubbing'])).toEqual({
+      live: {
+        controls: {
+          timeline: {
+            mode: 'below',
+            pan_mode: 'seek',
+          },
+        },
+      },
+      media_viewer: {
+        controls: {
+          timeline: {
+            mode: 'below',
+            style: 'ribbon',
+            pan_mode: 'seek',
+          },
+        },
+      },
+    });
+  });
+});
diff --git a/tests/config/profiles/low-performance.test.ts b/tests/config/profiles/low-performance.test.ts
new file mode 100644
index 00000000..c048d020
--- /dev/null
+++ b/tests/config/profiles/low-performance.test.ts
@@ -0,0 +1,54 @@
+import { expect, it } from 'vitest';
+import { LOW_PERFORMANCE_PROFILE } from '../../../src/config/profiles/low-performance';
+
+it('low performance profile', () => {
+  expect(LOW_PERFORMANCE_PROFILE).toEqual({
+    'cameras_global.image.refresh_seconds': 10,
+    'cameras_global.live_provider': 'image',
+    'cameras_global.triggers.occupancy': false,
+    'live.auto_mute': 'never',
+    'live.controls.thumbnails.mode': 'none',
+    'live.controls.thumbnails.show_details': false,
+    'live.controls.thumbnails.show_download_control': false,
+    'live.controls.thumbnails.show_favorite_control': false,
+    'live.controls.thumbnails.show_timeline_control': false,
+    'live.controls.timeline.show_recordings': false,
+    'live.controls.title.mode': 'none',
+    'live.draggable': false,
+    'live.lazy_unload': 'all',
+    'live.show_image_during_load': false,
+    'live.transition_effect': 'none',
+    'media_gallery.controls.thumbnails.show_details': false,
+    'media_gallery.controls.thumbnails.show_download_control': false,
+    'media_gallery.controls.thumbnails.show_favorite_control': false,
+    'media_gallery.controls.thumbnails.show_timeline_control': false,
+    'media_viewer.auto_mute': 'never',
+    'media_viewer.auto_pause': 'never',
+    'media_viewer.auto_play': 'never',
+    'media_viewer.controls.next_previous.style': 'chevrons',
+    'media_viewer.controls.thumbnails.mode': 'none',
+    'media_viewer.controls.thumbnails.show_details': false,
+    'media_viewer.controls.thumbnails.show_download_control': false,
+    'media_viewer.controls.thumbnails.show_favorite_control': false,
+    'media_viewer.controls.thumbnails.show_timeline_control': false,
+    'media_viewer.controls.timeline.show_recordings': false,
+    'media_viewer.controls.title.mode': 'none',
+    'media_viewer.draggable': false,
+    'media_viewer.snapshot_click_plays_clip': false,
+    'media_viewer.transition_effect': 'none',
+    'menu.buttons.frigate.enabled': false,
+    'menu.buttons.media_player.enabled': false,
+    'menu.buttons.timeline.enabled': false,
+    'menu.style': 'outside',
+    'performance.features.animated_progress_indicator': false,
+    'performance.features.media_chunk_size': 10,
+    'performance.style.border_radius': false,
+    'performance.style.box_shadow': false,
+    'timeline.controls.thumbnails.mode': 'none',
+    'timeline.controls.thumbnails.show_details': false,
+    'timeline.controls.thumbnails.show_download_control': false,
+    'timeline.controls.thumbnails.show_favorite_control': false,
+    'timeline.controls.thumbnails.show_timeline_control': false,
+    'timeline.show_recordings': false,
+  });
+});
diff --git a/tests/config/profiles/scrubbing.test.ts b/tests/config/profiles/scrubbing.test.ts
new file mode 100644
index 00000000..b043dc0e
--- /dev/null
+++ b/tests/config/profiles/scrubbing.test.ts
@@ -0,0 +1,13 @@
+import { expect, it } from 'vitest';
+import { SCRUBBING_PROFILE } from '../../../src/config/profiles/scrubbing';
+
+it('scrubbing profile', () => {
+  expect(SCRUBBING_PROFILE).toEqual({
+    'live.controls.timeline.mode': 'below',
+    'live.controls.timeline.style': 'ribbon',
+    'live.controls.timeline.pan_mode': 'seek',
+    'media_viewer.controls.timeline.mode': 'below',
+    'media_viewer.controls.timeline.style': 'ribbon',
+    'media_viewer.controls.timeline.pan_mode': 'seek',
+  });
+});
diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts
index 9ed3a817..76ec3350 100644
--- a/tests/config/types.test.ts
+++ b/tests/config/types.test.ts
@@ -253,7 +253,6 @@ describe('config defaults', () => {
           animated_progress_indicator: true,
           media_chunk_size: 50,
         },
-        profile: 'high',
         style: {
           border_radius: true,
           box_shadow: true,