feat: Implement basic general folder support (#2051)

- Related: #1748
This commit is contained in:
Dermot Duffy
2025-05-21 19:59:21 -07:00
committed by GitHub
parent 2eb0d9e35e
commit c6a4c8aea2
350 changed files with 12837 additions and 4509 deletions
+1 -5
View File
@@ -1,8 +1,4 @@
import cloneDeep from 'lodash-es/cloneDeep';
import get from 'lodash-es/get';
import isEqual from 'lodash-es/isEqual';
import set from 'lodash-es/set';
import unset from 'lodash-es/unset';
import { cloneDeep, get, isEqual, set, unset } from 'lodash-es';
import {
CONF_AUTOMATIONS,
CONF_CAMERAS,
@@ -0,0 +1,10 @@
import { z } from 'zod';
import { advancedCameraCardCustomActionsBaseSchema } from './base';
export const folderActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend(
{
advanced_camera_card_action: z.literal('folder'),
folder: z.string().optional(),
},
);
export type FolderActionConfig = z.infer<typeof folderActionConfigSchema>;
+1 -2
View File
@@ -7,8 +7,7 @@ const PTZ_BASE_ACTIONS = [...PTZ_PAN_TILT_ACTIONS, ...PTZ_ZOOM_ACTIONS] as const
export type PTZBaseAction = (typeof PTZ_BASE_ACTIONS)[number];
// PTZ actions as used by the PTZ control (includes a 'home' button).
const PTZ_CONTROL_ACTIONS = [...PTZ_BASE_ACTIONS, 'home'] as const;
export type PTZControlAction = (typeof PTZ_CONTROL_ACTIONS)[number];
export type PTZControlAction = PTZBaseAction | 'home';
// PTZ actions as used by the camera manager (includes generic presets).
export const PTZ_ACTIONS = [...PTZ_BASE_ACTIONS, 'preset'] as const;
+17 -2
View File
@@ -1,8 +1,23 @@
import { z } from 'zod';
import { VIEWS_USER_SPECIFIED } from '../../common/const';
import {
AdvancedCameraCardUserSpecifiedView,
VIEWS_USER_SPECIFIED,
} from '../../common/const';
import { advancedCameraCardCustomActionsBaseSchema } from './base';
type AdvancedCameraCardUserSpecifiedViewWithoutFolder = Exclude<
AdvancedCameraCardUserSpecifiedView,
'folder'
>;
export const viewActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend({
advanced_camera_card_action: z.enum(VIEWS_USER_SPECIFIED),
advanced_camera_card_action: z.enum(
// The folder view is handled by the `folder` action since it accepts an
// optional folder ID.
VIEWS_USER_SPECIFIED.filter((view) => view !== 'folder') as [
AdvancedCameraCardUserSpecifiedViewWithoutFolder,
...AdvancedCameraCardUserSpecifiedViewWithoutFolder[],
],
),
});
export type ViewActionConfig = z.infer<typeof viewActionConfigSchema>;
+2
View File
@@ -3,6 +3,7 @@ import { statusBarItemBaseSchema } from '../common/status-bar';
import { advancedCameraCardCustomActionsBaseSchema } from './custom/base';
import { cameraSelectActionConfigSchema } from './custom/camera-select';
import { viewDisplayModeActionConfigSchema } from './custom/display-mode';
import { folderActionConfigSchema } from './custom/folder';
import { generalActionConfigSchema } from './custom/general';
import { internalCallbackActionConfigSchema } from './custom/internal';
import { logActionConfigSchema } from './custom/log';
@@ -41,6 +42,7 @@ export const statusBarActionConfigSchema: z.ZodSchema<
const advancedCameraCardCustomActionSchema = z.union([
cameraSelectActionConfigSchema,
folderActionConfigSchema,
generalActionConfigSchema,
internalCallbackActionConfigSchema,
logActionConfigSchema,
+5 -9
View File
@@ -16,6 +16,7 @@ export const VIEWS_USER_SPECIFIED = [
'live',
'clip',
'clips',
'folder',
'snapshot',
'snapshots',
'recording',
@@ -24,12 +25,7 @@ export const VIEWS_USER_SPECIFIED = [
'timeline',
] as const;
export type AdvancedCameraCardUserSpecifiedView = (typeof VIEWS_USER_SPECIFIED)[number];
const VIEWS = [
...VIEWS_USER_SPECIFIED,
'diagnostics',
// Media: A generic piece of media (could be clip, snapshot, recording).
'media',
] as const;
export type AdvancedCameraCardView = (typeof VIEWS)[number];
export type AdvancedCameraCardView =
| AdvancedCameraCardUserSpecifiedView
| 'media'
| 'diagnostics';
@@ -2,10 +2,11 @@ import { z } from 'zod';
// The min/max width thumbnail.
export const THUMBNAIL_WIDTH_MIN = 75;
export const THUMBNAIL_WIDTH_DEFAULT = 100;
export const THUMBNAIL_WIDTH_MAX = 300;
const thumbnailControlsBaseDefaults = {
size: 100,
size: THUMBNAIL_WIDTH_DEFAULT,
show_details: true,
show_favorite_control: true,
show_timeline_control: true,
@@ -13,7 +14,7 @@ const thumbnailControlsBaseDefaults = {
};
// Configuration for the actual rendered thumbnail.
const thumbnailsControlBaseSchema = z.object({
export const thumbnailsControlBaseSchema = z.object({
size: z
.number()
.min(THUMBNAIL_WIDTH_MIN)
+13
View File
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const regexSchema = z.string().refine(
(val) => {
try {
new RegExp(val);
} catch {
return false;
}
return true;
},
{ message: 'Invalid regular expression' },
);
@@ -1,21 +1,9 @@
import { z } from 'zod';
import { regexSchema } from '../../common/regex';
export const userAgentConditionSchema = z.object({
condition: z.literal('user_agent'),
user_agent: z.string().optional(),
user_agent_re: z
.string()
.refine(
(val) => {
try {
new RegExp(val);
} catch {
return false;
}
return true;
},
{ message: 'Invalid regular expression' },
)
.optional(),
user_agent_re: regexSchema.optional(),
companion: z.boolean().optional(),
});
@@ -6,10 +6,10 @@ import { menuBaseSchema } from './base';
export const menuSubmenuItemSchema = elementsBaseSchema.extend({
entity: z.string().optional(),
icon: z.string().optional(),
state_color: z.boolean().default(true),
selected: z.boolean().default(false),
state_color: z.boolean().default(true).optional(),
selected: z.boolean().default(false).optional(),
subtitle: z.string().optional(),
enabled: z.boolean().default(true),
enabled: z.boolean().default(true).optional(),
});
export type MenuSubmenuItem = z.infer<typeof menuSubmenuItemSchema>;
+76
View File
@@ -0,0 +1,76 @@
import { NonEmptyTuple } from 'type-fest';
import { z } from 'zod';
import { AdvancedCameraCardError } from '../../types';
import { isTruthy } from '../../utils/basic';
import { regexSchema } from './common/regex';
export const HA_MEDIA_SOURCE_ROOT = 'media-source://';
export const folderTypeSchema = z.enum(['ha']);
export type FolderType = z.infer<typeof folderTypeSchema>;
const folderConfigDefault = {
type: 'ha' as const,
ha: {},
};
const haFolderPathComponentSchema = z.object({
id: z.string().optional(),
title: z.string().optional(),
title_re: regexSchema.optional(),
});
export type HAFolderPathComponent = z.infer<typeof haFolderPathComponentSchema>;
export const transformPathURLToPathArray = (
url: string,
): NonEmptyTuple<HAFolderPathComponent> => {
let urlPath = url;
try {
const urlObj = new URL(url);
urlPath = urlObj.pathname;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {}
const splitPath = decodeURIComponent(urlPath).split(',').filter(isTruthy).slice(1);
// HA uses a pretty odd URL protocol for media-browser URLs:
// - The URL is an encoded comma-separated value representing the folder
// hierarchy
// - The first component will be `media-browser/browser` representing the
// root
// - Each subsequent component will start with `media-source://<path>`
// - All components except the last will additionally include
// '/<media-class>'.
const folderPath: NonEmptyTuple<HAFolderPathComponent> = [
{ id: HA_MEDIA_SOURCE_ROOT },
...splitPath.slice(0, -1).map((split) => ({ id: split.replace(/\/[^/]+$/, '') })),
...splitPath.slice(-1).map((split) => ({ id: split })),
];
for (const component of folderPath) {
if (component.id && !component.id.startsWith(HA_MEDIA_SOURCE_ROOT)) {
throw new AdvancedCameraCardError(
`Could not parse valid media source URL: ${url}`,
);
}
}
return folderPath;
};
const haFolderConfigSchema = z.object({
url: z.string().transform(transformPathURLToPathArray).optional(),
path: haFolderPathComponentSchema.array().nonempty().optional(),
});
export type HAFolderConfig = z.infer<typeof haFolderConfigSchema>;
const folderConfigSchema = z.object({
type: folderTypeSchema.default(folderConfigDefault.type),
id: z.string().optional(),
ha: haFolderConfigSchema.default(folderConfigDefault.ha).optional(),
title: z.string().optional(),
icon: z.string().optional(),
});
export type FolderConfig = z.infer<typeof folderConfigSchema>;
export const foldersConfigSchema = folderConfigSchema.array();
-45
View File
@@ -1,45 +0,0 @@
import { z } from 'zod';
import { actionsSchema } from './actions/types';
import {
thumbnailControlsDefaults,
thumbnailsControlSchema,
} from './common/controls/thumbnails';
const galleryThumbnailControlsDefaults = {
...thumbnailControlsDefaults,
show_details: false,
};
export const galleryConfigDefault = {
controls: {
thumbnails: galleryThumbnailControlsDefaults,
filter: {
mode: 'right' as const,
},
},
};
const gallerythumbnailsControlSchema = thumbnailsControlSchema.extend({
show_details: z.boolean().default(galleryThumbnailControlsDefaults.show_details),
});
export const galleryConfigSchema = z
.object({
controls: z
.object({
thumbnails: gallerythumbnailsControlSchema.default(
galleryConfigDefault.controls.thumbnails,
),
filter: z
.object({
mode: z
.enum(['none', 'left', 'right'])
.default(galleryConfigDefault.controls.filter.mode),
})
.default(galleryConfigDefault.controls.filter),
})
.default(galleryConfigDefault.controls),
})
.merge(actionsSchema)
.default(galleryConfigDefault);
export type GalleryConfig = z.infer<typeof galleryConfigSchema>;
+48
View File
@@ -0,0 +1,48 @@
import { z } from 'zod';
import { actionsSchema } from './actions/types';
import {
thumbnailControlsDefaults,
thumbnailsControlBaseSchema,
} from './common/controls/thumbnails';
const mediaGalleryThumbnailControlsDefaults = {
...thumbnailControlsDefaults,
show_details: false,
};
export const mediaGalleryConfigDefault = {
controls: {
thumbnails: mediaGalleryThumbnailControlsDefaults,
filter: {
mode: 'right' as const,
},
},
};
const mediaGallerythumbnailsControlSchema = thumbnailsControlBaseSchema.extend({
show_details: z.boolean().default(mediaGalleryThumbnailControlsDefaults.show_details),
});
export type MediaGalleryThumbnailsConfig = z.infer<
typeof mediaGallerythumbnailsControlSchema
>;
export const mediaGalleryConfigSchema = z
.object({
controls: z
.object({
thumbnails: mediaGallerythumbnailsControlSchema.default(
mediaGalleryConfigDefault.controls.thumbnails,
),
filter: z
.object({
mode: z
.enum(['none', 'left', 'right'])
.default(mediaGalleryConfigDefault.controls.filter.mode),
})
.default(mediaGalleryConfigDefault.controls.filter),
})
.default(mediaGalleryConfigDefault.controls),
})
.merge(actionsSchema)
.default(mediaGalleryConfigDefault);
export type MediaGalleryConfig = z.infer<typeof mediaGalleryConfigSchema>;
+2
View File
@@ -34,6 +34,7 @@ export const menuConfigDefault = {
display_mode: visibleButtonDefault,
download: visibleButtonDefault,
expand: hiddenButtonDefault,
folders: visibleButtonDefault,
iris: visibleButtonDefault,
fullscreen: visibleButtonDefault,
image: hiddenButtonDefault,
@@ -82,6 +83,7 @@ export const menuConfigSchema = z
),
download: visibleButtonSchema.default(menuConfigDefault.buttons.download),
expand: hiddenButtonSchema.default(menuConfigDefault.buttons.expand),
folders: visibleButtonSchema.default(menuConfigDefault.buttons.folders),
iris: visibleButtonSchema.default(menuConfigDefault.buttons.iris),
fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen),
image: hiddenButtonSchema.default(menuConfigDefault.buttons.image),
+6 -3
View File
@@ -8,9 +8,10 @@ import { imageConfigDefault } from './common/image';
import { DebugConfig, debugConfigDefault, debugConfigSchema } from './debug';
import { dimensionsConfigSchema } from './dimensions';
import { pictureElementsSchema } from './elements/types';
import { galleryConfigDefault, galleryConfigSchema } from './gallery';
import { foldersConfigSchema } from './folders';
import { imageConfigSchema } from './image';
import { liveConfigDefault, liveConfigSchema } from './live';
import { mediaGalleryConfigDefault, mediaGalleryConfigSchema } from './media-gallery';
import { menuConfigDefault, menuConfigSchema } from './menu';
import { overridesSchema } from './overrides';
import {
@@ -43,7 +44,7 @@ export const advancedCameraCardConfigSchema = z.object({
menu: menuConfigSchema,
status_bar: statusBarConfigSchema,
live: liveConfigSchema,
media_gallery: galleryConfigSchema,
media_gallery: mediaGalleryConfigSchema,
media_viewer: viewerConfigSchema,
image: imageConfigSchema,
elements: pictureElementsSchema,
@@ -55,6 +56,8 @@ export const advancedCameraCardConfigSchema = z.object({
profiles: profilesSchema,
folders: foldersConfigSchema.optional(),
// Configuration overrides.
overrides: overridesSchema,
@@ -78,7 +81,7 @@ export const configDefaults = {
menu: menuConfigDefault,
status_bar: statusBarConfigDefault,
live: liveConfigDefault,
media_gallery: galleryConfigDefault,
media_gallery: mediaGalleryConfigDefault,
media_viewer: viewerConfigDefault,
image: imageConfigDefault,
timeline: timelineConfigDefault,
+8 -10
View File
@@ -36,16 +36,14 @@ const keyboardShortcutsSchema = z.object({
});
export type KeyboardShortcuts = z.infer<typeof keyboardShortcutsSchema>;
const KEYBOARD_SHORTCUT_PTZ_NAMES = [
'ptz_down',
'ptz_home',
'ptz_left',
'ptz_right',
'ptz_up',
'ptz_zoom_in',
'ptz_zoom_out',
] as const;
export type PTZKeyboardShortcutName = (typeof KEYBOARD_SHORTCUT_PTZ_NAMES)[number];
export type PTZKeyboardShortcutName =
| 'ptz_down'
| 'ptz_home'
| 'ptz_left'
| 'ptz_right'
| 'ptz_up'
| 'ptz_zoom_in'
| 'ptz_zoom_out';
export const viewConfigDefault = {
default: VIEW_DEFAULT,