{
- if (
- !this.hass ||
- !this.cameras ||
- !this.view ||
- !this._timeline ||
- !this.timelineConfig
- ) {
+ if (!this.hass || !this.cameras || !this.view || !this.timelineConfig) {
return;
}
@@ -765,15 +1048,20 @@ export class FrigateCardTimelineCore extends LitElement {
? this._getStartEndFromEvent(event)
: this._getStartEnd();
- await this._events.fetchEventsIfNecessary(
+ await this._data.fetchIfNecessary(
this,
this.hass,
this.cameras,
this.timelineConfig.media,
windowStart,
windowEnd,
+ this.timelineConfig.show_recordings,
);
+ if (!this._timeline) {
+ return;
+ }
+
this._timeline.setSelection(event ? [event.id] : [], {
focus: false,
animation: {
@@ -807,9 +1095,9 @@ export class FrigateCardTimelineCore extends LitElement {
// Hack: Clustering may not update unless the dataset changes, artifically
// update the dataset to ensure the newly selected item cannot be included
// in a cluster.
- const item = this._events.dataset.get(event.id);
+ const item = this._data.dataset.get(event.id);
if (item) {
- this._events.dataset.updateOnly(item);
+ this._data.dataset.updateOnly(item);
}
}
} else {
@@ -825,7 +1113,7 @@ export class FrigateCardTimelineCore extends LitElement {
// -> New view dispatched (to load thumbnails into outer carousel).
// -> New view received ... [loop]
const currentContext = this.view.context as TimelineViewContext | null;
- if (currentContext?.dateFetch !== this._events.lastFetchDate) {
+ if (!isEqual(currentContext?.dateFetch, this._data.lastFetchDate)) {
const thumbnails = this._generateThumbnails();
this.view
?.evolve({
@@ -851,12 +1139,27 @@ export class FrigateCardTimelineCore extends LitElement {
} else if (currentContext?.window) {
newContext.window = currentContext.window;
}
- if (this._events.lastFetchDate) {
- newContext.dateFetch = this._events.lastFetchDate;
+ if (this._data.lastFetchDate) {
+ newContext.dateFetch = this._data.lastFetchDate;
}
return newContext || null;
}
+ /**
+ * Called when an update will occur.
+ * @param changedProps The changed properties
+ */
+ protected willUpdate(changedProps: PropertyValues): void {
+ if (changedProps.has('timelineConfig')) {
+ if (this.timelineConfig?.controls.thumbnails.size) {
+ this.style.setProperty(
+ '--frigate-card-thumbnail-size',
+ `${this.timelineConfig.controls.thumbnails.size}px`,
+ );
+ }
+ }
+ }
+
/**
* Called when the component is updated.
* @param changedProperties The changed properties if any.
@@ -865,7 +1168,7 @@ export class FrigateCardTimelineCore extends LitElement {
super.updated(changedProperties);
if (changedProperties.has('cameras')) {
- this._events.clear();
+ this._data.clear();
this._timeline?.destroy();
this._timeline = undefined;
}
@@ -888,14 +1191,14 @@ export class FrigateCardTimelineCore extends LitElement {
this._timeline = new Timeline(
this._refTimeline.value,
- this._events.dataset,
+ this._data.dataset,
groups,
options,
);
this._timeline.on('select', this._timelineSelectHandler.bind(this));
this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this));
this._timeline.on('click', this._timelineClickHandler.bind(this));
- this._timeline.on('doubleclick', this._timelineClickHandler.bind(this));
+ this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this));
}
}
diff --git a/src/components/viewer.ts b/src/components/viewer.ts
index f48975c6..0304949a 100644
--- a/src/components/viewer.ts
+++ b/src/components/viewer.ts
@@ -22,6 +22,7 @@ import type {
CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
+ FrigateCardMediaPlayer,
MediaShowInfo,
TransitionEffect,
ViewerConfig
@@ -36,8 +37,8 @@ import {
multipleBrowseMediaQueryMerged,
overrideMultiBrowseMediaQueryParameters
} from '../utils/ha/browse-media.js';
-import { createMediaShowInfo } from '../utils/media-info.js';
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
+import { createMediaShowInfo } from '../utils/media-info.js';
import { View } from '../view.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js';
@@ -125,6 +126,8 @@ export class FrigateCardViewer extends LitElement {
}
}
+const FRIGATE_CARD_HLS_SELECTOR = 'frigate-card-ha-hls-player';
+
@customElement('frigate-card-viewer-carousel')
export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
@property({ attribute: false })
@@ -168,11 +171,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
++i
) {
if (isTrueMedia(target.children[i])) {
- await resolveMedia(
- this.hass,
- target.children[i],
- this.resolvedMediaCache,
- );
+ await resolveMedia(this.hass, target.children[i], this.resolvedMediaCache);
}
}
},
@@ -278,6 +277,23 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
};
}
+ /**
+ * The the HLS player on a slide (or current slide if not provided.)
+ * @param slide An optional slide.
+ * @returns The FrigateCardMediaPlayer or null if not found.
+ */
+ protected _getPlayer(slide?: HTMLElement): FrigateCardMediaPlayer | null {
+ if (this._carousel) {
+ if (!slide) {
+ slide = this._carousel.slideNodes()[this._carousel.selectedScrollSnap()];
+ }
+ return slide?.querySelector(
+ FRIGATE_CARD_HLS_SELECTOR,
+ ) as FrigateCardMediaPlayer | null;
+ }
+ return null;
+ }
+
/**
* Get the Embla plugins to use.
* @returns An EmblaOptionsType object or undefined for no options.
@@ -291,7 +307,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
}),
}),
AutoMediaPlugin({
- playerSelector: 'frigate-card-ha-hls-player',
+ playerSelector: FRIGATE_CARD_HLS_SELECTOR,
...(this.viewerConfig?.auto_play && {
autoPlayCondition: this.viewerConfig.auto_play,
}),
@@ -511,9 +527,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
const img = slide.querySelector('img') as HTMLImageElement;
// Frigate >= 0.9.0+ clips.
- const hls_player = slide.querySelector(
- 'frigate-card-ha-hls-player',
- ) as HTMLElement & { url: string };
+ const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
+ url: string;
+ };
if (img) {
img.src = this._canonicalizeHAURL(resolvedMedia.url) || '';
@@ -661,6 +677,24 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
: ``} `;
}
+ /**
+ * Fire a media show event when a slide is selected.
+ */
+ protected _selectSlideMediaShowHandler(): void {
+ super._selectSlideMediaShowHandler();
+
+ // If this is a recording and play is desired to be started from a
+ // particular point, seek to that point. Use the media off the slide itself
+ // -- when the slide is changed, the media show event may be dispatched
+ // before this.view has been updated to reflect the new selection.
+ const player = this._getPlayer() as FrigateCardMediaPlayer & {
+ media?: FrigateBrowseMediaSource;
+ };
+ if (player && player.media && player.media.frigate?.recording?.seek_seconds) {
+ player.seek(player.media.frigate.recording.seek_seconds);
+ }
+ }
+
protected _renderMediaItem(
mediaToRender: FrigateBrowseMediaSource,
slideIndex: number,
@@ -682,6 +716,8 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
return;
}
+ // The media is attached to the player as '.media' which is used in
+ // `_selectSlideMediaShowHandler` (and not used by the player itself).
return html`
${mediaToRender.media_content_type === 'video'
@@ -696,6 +732,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
url=${ifDefined(
lazyLoad ? undefined : this._canonicalizeHAURL(resolvedMedia?.url),
)}
+ .media=${mediaToRender}
.hass=${this.hass}
@frigate-card:media-show=${(e: CustomEvent) =>
this._mediaShowEventHandler(slideIndex, e)}
diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts
index b368af9f..555b9ca6 100644
--- a/src/config-mgmt.ts
+++ b/src/config-mgmt.ts
@@ -8,42 +8,38 @@ import {
CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
CONF_CAMERAS_ARRAY_URL,
CONF_CAMERAS_ARRAY_ZONE,
- CONF_EVENT_VIEWER_AUTO_PLAY,
- CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
- CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
- CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE,
CONF_IMAGE_URL,
+ CONF_LIVE_AUTO_UNMUTE,
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
CONF_LIVE_LAZY_UNLOAD,
CONF_LIVE_PRELOAD,
CONF_LIVE_WEBRTC_CARD,
+ CONF_MEDIA_VIEWER,
CONF_MENU,
- CONF_MENU_BUTTONS_FRIGATE,
CONF_MENU_BUTTONS_CAMERAS,
- CONF_MENU_BUTTONS_LIVE,
CONF_MENU_BUTTONS_CLIPS,
- CONF_MENU_BUTTONS_SNAPSHOTS,
- CONF_MENU_BUTTONS_IMAGE,
CONF_MENU_BUTTONS_DOWNLOAD,
+ CONF_MENU_BUTTONS_FRIGATE,
CONF_MENU_BUTTONS_FRIGATE_UI,
CONF_MENU_BUTTONS_FULLSCREEN,
+ CONF_MENU_BUTTONS_IMAGE,
+ CONF_MENU_BUTTONS_LIVE,
+ CONF_MENU_BUTTONS_SNAPSHOTS,
CONF_MENU_BUTTON_SIZE,
CONF_MENU_POSITION,
CONF_MENU_STYLE,
CONF_OVERRIDES,
CONF_VIEW_DEFAULT,
CONF_VIEW_TIMEOUT_SECONDS,
- CONF_VIEW_UPDATE_ENTITIES,
- CONF_LIVE_AUTO_UNMUTE,
- CONF_EVENT_VIEWER_AUTO_UNMUTE,
+ CONF_VIEW_UPDATE_ENTITIES
} from './const';
import {
BUTTON_SIZE_MIN,
RawFrigateCardConfig,
RawFrigateCardConfigArray,
THUMBNAIL_WIDTH_MAX,
- THUMBNAIL_WIDTH_MIN,
+ THUMBNAIL_WIDTH_MIN
} from './types';
/**
@@ -532,8 +528,8 @@ const UPGRADES = [
upgradeMoveTo('live_preload', CONF_LIVE_PRELOAD),
upgradeMoveTo('webrtc', 'live.webrtc'),
upgradeMoveTo('autoplay_clip', 'event_viewer.autoplay_clip'),
- upgradeMoveTo('controls.nextprev', CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE),
- upgradeMoveTo('controls.nextprev_size', CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE),
+ upgradeMoveTo('controls.nextprev', 'event_viewer.controls.next_previous.style'),
+ upgradeMoveTo('controls.nextprev_size', 'event_viewer.controls.next_previous.size'),
upgradeMoveTo('menu_mode', 'menu.mode'),
upgradeMoveTo('menu_buttons', 'menu.buttons'),
upgradeMoveTo('menu_button_size', CONF_MENU_BUTTON_SIZE),
@@ -546,7 +542,7 @@ const UPGRADES = [
upgradeToMultipleCameras(),
upgradeMenuConditionToMenuOverride(),
upgradeMoveTo('view.timeout', CONF_VIEW_TIMEOUT_SECONDS, toNumberOrIgnore),
- upgradeMoveTo('event_viewer.autoplay_clip', CONF_EVENT_VIEWER_AUTO_PLAY),
+ upgradeMoveTo('event_viewer.autoplay_clip', 'event_viewer.auto_play'),
// v3.0.0-rc.1 -> v3.0.0-rc.2
upgradeArrayValue(
@@ -565,7 +561,7 @@ const UPGRADES = [
createRangedTransform(toPixelsOrDelete, THUMBNAIL_WIDTH_MIN, THUMBNAIL_WIDTH_MAX),
),
upgradeWithOverrides(
- CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE,
+ 'event_viewer.controls.thumbnails.size',
createRangedTransform(toPixelsOrDelete, THUMBNAIL_WIDTH_MIN, THUMBNAIL_WIDTH_MAX),
),
upgradeWithOverrides(
@@ -573,7 +569,7 @@ const UPGRADES = [
createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN),
),
upgradeWithOverrides(
- CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
+ 'event_viewer.controls.next_previous.size',
createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN),
),
upgradeWithOverrides(
@@ -597,10 +593,11 @@ const UPGRADES = [
upgrade(CONF_LIVE_AUTO_UNMUTE, (val) =>
typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
),
- upgrade(CONF_EVENT_VIEWER_AUTO_PLAY, (val) =>
+ upgrade('event_viewer.auto_play', (val) =>
typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
),
- upgrade(CONF_EVENT_VIEWER_AUTO_UNMUTE, (val) =>
+ upgrade('event_viewer.auto_unmute', (val) =>
typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
),
+ upgradeMoveToWithOverrides('event_viewer', CONF_MEDIA_VIEWER),
];
diff --git a/src/const.ts b/src/const.ts
index 717e82f4..bd081db1 100644
--- a/src/const.ts
+++ b/src/const.ts
@@ -52,31 +52,31 @@ export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_CONTROLS =
export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE =
`${CONF_EVENT_GALLERY}.controls.thumbnails.size` as const;
-export const CONF_EVENT_VIEWER = 'event_viewer' as const;
-export const CONF_EVENT_VIEWER_AUTO_PLAY = `${CONF_EVENT_VIEWER}.auto_play` as const;
-export const CONF_EVENT_VIEWER_AUTO_PAUSE = `${CONF_EVENT_VIEWER}.auto_pause` as const;
-export const CONF_EVENT_VIEWER_AUTO_MUTE = `${CONF_EVENT_VIEWER}.auto_mute` as const;
-export const CONF_EVENT_VIEWER_AUTO_UNMUTE = `${CONF_EVENT_VIEWER}.auto_unmute` as const;
-export const CONF_EVENT_VIEWER_DRAGGABLE = `${CONF_EVENT_VIEWER}.draggable` as const;
-export const CONF_EVENT_VIEWER_LAZY_LOAD = `${CONF_EVENT_VIEWER}.lazy_load` as const;
-export const CONF_EVENT_VIEWER_TRANSITION_EFFECT =
- `${CONF_EVENT_VIEWER}.transition_effect` as const;
-export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE =
- `${CONF_EVENT_VIEWER}.controls.next_previous.style` as const;
-export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE =
- `${CONF_EVENT_VIEWER}.controls.next_previous.size` as const;
-export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE =
- `${CONF_EVENT_VIEWER}.controls.thumbnails.mode` as const;
-export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS =
- `${CONF_EVENT_VIEWER}.controls.thumbnails.show_details` as const;
-export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS =
- `${CONF_EVENT_VIEWER}.controls.thumbnails.show_controls` as const;
-export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE =
- `${CONF_EVENT_VIEWER}.controls.thumbnails.size` as const;
-export const CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE =
- `${CONF_EVENT_VIEWER}.controls.title.mode` as const;
-export const CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS =
- `${CONF_EVENT_VIEWER}.controls.title.duration_seconds` as const;
+export const CONF_MEDIA_VIEWER = 'media_viewer' as const;
+export const CONF_MEDIA_VIEWER_AUTO_PLAY = `${CONF_MEDIA_VIEWER}.auto_play` as const;
+export const CONF_MEDIA_VIEWER_AUTO_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const;
+export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const;
+export const CONF_MEDIA_VIEWER_AUTO_UNMUTE = `${CONF_MEDIA_VIEWER}.auto_unmute` as const;
+export const CONF_MEDIA_VIEWER_DRAGGABLE = `${CONF_MEDIA_VIEWER}.draggable` as const;
+export const CONF_MEDIA_VIEWER_LAZY_LOAD = `${CONF_MEDIA_VIEWER}.lazy_load` as const;
+export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT =
+ `${CONF_MEDIA_VIEWER}.transition_effect` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE =
+ `${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE =
+ `${CONF_MEDIA_VIEWER}.controls.next_previous.size` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE =
+ `${CONF_MEDIA_VIEWER}.controls.thumbnails.mode` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS =
+ `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_details` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS =
+ `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_controls` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE =
+ `${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE =
+ `${CONF_MEDIA_VIEWER}.controls.title.mode` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS =
+ `${CONF_MEDIA_VIEWER}.controls.title.duration_seconds` as const;
export const CONF_LIVE = 'live' as const;
export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const;
@@ -118,6 +118,7 @@ export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as
export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
`${CONF_TIMELINE}.clustering_threshold` as const;
export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const;
+export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE =
`${CONF_TIMELINE}.controls.thumbnails.mode` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE =
diff --git a/src/editor.ts b/src/editor.ts
index f0bf24b7..6d818e1c 100644
--- a/src/editor.ts
+++ b/src/editor.ts
@@ -32,23 +32,7 @@ import {
CONF_DIMENSIONS_ASPECT_RATIO_MODE,
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
- CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE,
- CONF_EVENT_VIEWER_AUTO_MUTE,
- CONF_EVENT_VIEWER_AUTO_PAUSE,
- CONF_EVENT_VIEWER_AUTO_PLAY,
- CONF_EVENT_VIEWER_AUTO_UNMUTE,
- CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
- CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
- CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE,
- CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
- CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
- CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE,
- CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
- CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE,
- CONF_EVENT_VIEWER_DRAGGABLE,
- CONF_EVENT_VIEWER_LAZY_LOAD,
- CONF_EVENT_VIEWER_TRANSITION_EFFECT,
- CONF_IMAGE_MODE,
+ CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE, CONF_IMAGE_MODE,
CONF_IMAGE_REFRESH_SECONDS,
CONF_IMAGE_URL,
CONF_LIVE_AUTO_MUTE,
@@ -68,8 +52,21 @@ import {
CONF_LIVE_LAZY_LOAD,
CONF_LIVE_LAZY_UNLOAD,
CONF_LIVE_PRELOAD,
- CONF_LIVE_TRANSITION_EFFECT,
- CONF_MENU_ALIGNMENT,
+ CONF_LIVE_TRANSITION_EFFECT, CONF_MEDIA_VIEWER_AUTO_MUTE,
+ CONF_MEDIA_VIEWER_AUTO_PAUSE,
+ CONF_MEDIA_VIEWER_AUTO_PLAY,
+ CONF_MEDIA_VIEWER_AUTO_UNMUTE,
+ CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
+ CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
+ CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
+ CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
+ CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
+ CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE,
+ CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
+ CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
+ CONF_MEDIA_VIEWER_DRAGGABLE,
+ CONF_MEDIA_VIEWER_LAZY_LOAD,
+ CONF_MEDIA_VIEWER_TRANSITION_EFFECT, CONF_MENU_ALIGNMENT,
CONF_MENU_BUTTONS,
CONF_MENU_BUTTON_SIZE,
CONF_MENU_POSITION,
@@ -80,6 +77,7 @@ import {
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
CONF_TIMELINE_MEDIA,
+ CONF_TIMELINE_SHOW_RECORDINGS,
CONF_TIMELINE_WINDOW_SECONDS,
CONF_VIEW_CAMERA_SELECT,
CONF_VIEW_DARK_MODE,
@@ -152,10 +150,10 @@ const options: EditorOptions = {
name: localize('editor.live'),
secondary: localize('editor.live_secondary'),
},
- event_viewer: {
+ media_viewer: {
icon: 'filmstrip',
- name: localize('editor.event_viewer'),
- secondary: localize('editor.event_viewer_secondary'),
+ name: localize('editor.media_viewer'),
+ secondary: localize('editor.media_viewer_secondary'),
},
event_gallery: {
icon: 'grid',
@@ -239,15 +237,15 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: '', label: '' },
{
value: 'thumbnails',
- label: localize('config.event_viewer.controls.next_previous.styles.thumbnails'),
+ label: localize('config.media_viewer.controls.next_previous.styles.thumbnails'),
},
{
value: 'chevrons',
- label: localize('config.event_viewer.controls.next_previous.styles.chevrons'),
+ label: localize('config.media_viewer.controls.next_previous.styles.chevrons'),
},
{
value: 'none',
- label: localize('config.event_viewer.controls.next_previous.styles.none'),
+ label: localize('config.media_viewer.controls.next_previous.styles.none'),
},
];
@@ -281,23 +279,23 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: '', label: '' },
{
value: 'none',
- label: localize('config.event_viewer.controls.thumbnails.modes.none'),
+ label: localize('config.media_viewer.controls.thumbnails.modes.none'),
},
{
value: 'above',
- label: localize('config.event_viewer.controls.thumbnails.modes.above'),
+ label: localize('config.media_viewer.controls.thumbnails.modes.above'),
},
{
value: 'below',
- label: localize('config.event_viewer.controls.thumbnails.modes.below'),
+ label: localize('config.media_viewer.controls.thumbnails.modes.below'),
},
{
value: 'left',
- label: localize('config.event_viewer.controls.thumbnails.modes.left'),
+ label: localize('config.media_viewer.controls.thumbnails.modes.left'),
},
{
value: 'right',
- label: localize('config.event_viewer.controls.thumbnails.modes.right'),
+ label: localize('config.media_viewer.controls.thumbnails.modes.right'),
},
];
@@ -312,29 +310,29 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
protected _titleModes: EditorSelectOption[] = [
{ value: '', label: '' },
- { value: 'none', label: localize('config.event_viewer.controls.title.modes.none') },
+ { value: 'none', label: localize('config.media_viewer.controls.title.modes.none') },
{
value: 'popup-top-left',
- label: localize('config.event_viewer.controls.title.modes.popup-top-left'),
+ label: localize('config.media_viewer.controls.title.modes.popup-top-left'),
},
{
value: 'popup-top-right',
- label: localize('config.event_viewer.controls.title.modes.popup-top-right'),
+ label: localize('config.media_viewer.controls.title.modes.popup-top-right'),
},
{
value: 'popup-bottom-left',
- label: localize('config.event_viewer.controls.title.modes.popup-bottom-left'),
+ label: localize('config.media_viewer.controls.title.modes.popup-bottom-left'),
},
{
value: 'popup-bottom-right',
- label: localize('config.event_viewer.controls.title.modes.popup-bottom-right'),
+ label: localize('config.media_viewer.controls.title.modes.popup-bottom-right'),
},
];
protected _transitionEffects: EditorSelectOption[] = [
{ value: '', label: '' },
- { value: 'none', label: localize('config.event_viewer.transition_effects.none') },
- { value: 'slide', label: localize('config.event_viewer.transition_effects.slide') },
+ { value: 'none', label: localize('config.media_viewer.transition_effects.none') },
+ { value: 'slide', label: localize('config.media_viewer.transition_effects.slide') },
];
protected _imageModes: EditorSelectOption[] = [
@@ -1134,74 +1132,74 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
})}
${this._renderSwitch(
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
- defaults.event_viewer.controls.thumbnails.show_details,
+ defaults.media_viewer.controls.thumbnails.show_details,
)}
${this._renderSwitch(
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
- defaults.event_viewer.controls.thumbnails.show_controls,
+ defaults.media_viewer.controls.thumbnails.show_controls,
)}
`
: ''}
- ${this._renderOptionSetHeader('event_viewer')}
- ${this._expandedMenus[MENU_OPTIONS] === 'event_viewer'
+ ${this._renderOptionSetHeader('media_viewer')}
+ ${this._expandedMenus[MENU_OPTIONS] === 'media_viewer'
? html`
${this._renderOptionSelector(
- CONF_EVENT_VIEWER_AUTO_PLAY,
+ CONF_MEDIA_VIEWER_AUTO_PLAY,
this._mediaActionPositiveConditions,
)}
${this._renderOptionSelector(
- CONF_EVENT_VIEWER_AUTO_PAUSE,
+ CONF_MEDIA_VIEWER_AUTO_PAUSE,
this._mediaActionNegativeConditions,
)}
${this._renderOptionSelector(
- CONF_EVENT_VIEWER_AUTO_MUTE,
+ CONF_MEDIA_VIEWER_AUTO_MUTE,
this._mediaActionNegativeConditions,
)}
${this._renderOptionSelector(
- CONF_EVENT_VIEWER_AUTO_UNMUTE,
+ CONF_MEDIA_VIEWER_AUTO_UNMUTE,
this._mediaActionPositiveConditions,
)}
${this._renderSwitch(
- CONF_EVENT_VIEWER_DRAGGABLE,
- defaults.event_viewer.draggable,
+ CONF_MEDIA_VIEWER_DRAGGABLE,
+ defaults.media_viewer.draggable,
)}
${this._renderSwitch(
- CONF_EVENT_VIEWER_LAZY_LOAD,
- defaults.event_viewer.lazy_load,
+ CONF_MEDIA_VIEWER_LAZY_LOAD,
+ defaults.media_viewer.lazy_load,
)}
${this._renderOptionSelector(
- CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
+ CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
this._eventViewerNextPreviousControlStyles,
)}
- ${this._renderNumberInput(CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, {
+ ${this._renderNumberInput(CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, {
min: BUTTON_SIZE_MIN,
})}
${this._renderOptionSelector(
- CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE,
+ CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
this._thumbnailModes,
)}
- ${this._renderNumberInput(CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE, {
+ ${this._renderNumberInput(CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE, {
min: THUMBNAIL_WIDTH_MIN,
max: THUMBNAIL_WIDTH_MAX,
})}
${this._renderSwitch(
- CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
- defaults.event_viewer.controls.thumbnails.show_details,
+ CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
+ defaults.media_viewer.controls.thumbnails.show_details,
)}
${this._renderSwitch(
- CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
- defaults.event_viewer.controls.thumbnails.show_controls,
+ CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
+ defaults.media_viewer.controls.thumbnails.show_controls,
)}
${this._renderOptionSelector(
- CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE,
+ CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
this._titleModes,
)}
${this._renderNumberInput(
- CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
+ CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
{ min: 0, max: 60 },
)}
${this._renderOptionSelector(
- CONF_EVENT_VIEWER_TRANSITION_EFFECT,
+ CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
this._transitionEffects,
)}
`
@@ -1223,6 +1221,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_TIMELINE_MEDIA,
this._timelineMediaTypes,
)}
+ ${this._renderSwitch(
+ CONF_TIMELINE_SHOW_RECORDINGS,
+ defaults.timeline.show_recordings,
+ )}
${this._renderOptionSelector(
CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
this._thumbnailModes,
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json
index 4f7dd1a5..75659d49 100644
--- a/src/localize/languages/en.json
+++ b/src/localize/languages/en.json
@@ -7,7 +7,8 @@
"no_clips": "No clips",
"no_snapshot": "No recent snapshot",
"no_clip": "No recent clip",
- "live": "Live"
+ "live": "Live",
+ "recordings": "Recordings"
},
"config": {
"cameras": {
@@ -84,22 +85,22 @@
}
}
},
- "event_viewer": {
+ "media_viewer": {
"auto_play": "Automatically play media",
"auto_pause": "Automatically pause media",
"auto_mute": "Automatically mute media",
"auto_unmute": "Automatically unmute media",
- "draggable": "Event Viewer can be dragged/swiped",
- "lazy_load": "Event Viewer media is lazily loaded in carousel",
- "transition_effect": "Event Viewer transition effect",
+ "draggable": "Media Viewer can be dragged/swiped",
+ "lazy_load": "Media Viewer media is lazily loaded in carousel",
+ "transition_effect": "Media Viewer transition effect",
"transition_effects": {
"none": "No transition",
"slide": "Slide transition"
},
"controls": {
"next_previous": {
- "style": "Event Viewer next & previous control style",
- "size": "Event Viewer next & previous control size in pixels",
+ "style": "Media Viewer next & previous control style",
+ "size": "Media Viewer next & previous control size in pixels",
"styles": {
"thumbnails": "Thumbnails",
"chevrons": "Chevrons",
@@ -107,10 +108,10 @@
}
},
"thumbnails": {
- "mode": "Event Viewer thumbnails mode",
- "size": "Event Viewer thumbnails size in pixels",
- "show_details": "Show event details with thumbnails",
- "show_controls": "Show event controls with thumbnails",
+ "mode": "Media Viewer thumbnails mode",
+ "size": "Media Viewer thumbnails size in pixels",
+ "show_details": "Show details with thumbnails",
+ "show_controls": "Show controls with thumbnails",
"modes": {
"below": "Thumbnails below the media",
"above": "Thumbnails above the media",
@@ -120,7 +121,7 @@
}
},
"title": {
- "mode": "Event Viewer media title display mode",
+ "mode": "Media Viewer media title display mode",
"modes": {
"none": "No title display",
"popup-top-left": "Popup on the top left",
@@ -229,6 +230,7 @@
"window_seconds": "The default length of the timeline view in seconds",
"clustering_threshold": "The count of events at which they are clustered (0=no clustering)",
"media": "The media the timeline displays",
+ "show_recordings": "Show recordings",
"medias": {
"all": "All media types",
"clips": "Clips",
@@ -267,8 +269,8 @@
"live_secondary": "Live camera view options",
"event_gallery": "Event gallery",
"event_gallery_secondary": "Snapshots & clips gallery options",
- "event_viewer": "Event viewer",
- "event_viewer_secondary": "Snapshots & clips viewer options",
+ "media_viewer": "Media viewer",
+ "media_viewer_secondary": "Viewer for static media (clips, snapshots or recordings)",
"image": "Image",
"image_secondary": "Static image view options",
"dimensions": "Dimensions",
@@ -292,13 +294,21 @@
"in_progress": "In Progress",
"score": "Score"
},
+ "recording": {
+ "events": "Events",
+ "seek": "Seek"
+ },
"thumbnail": {
"retain_indefinitely": "Event will be indefinitely retained",
- "timeline": "See event in timeline"
+ "timeline": "See event in timeline",
+ "no_thumbnail": "No thumbnail available"
},
"error": {
+ "undecodable_response": "Could not decode response from Home Assistant for request",
"empty_response": "Received empty response from Home Assistant for request",
"invalid_response": "Received invalid response from Home Assistant for request",
+ "failed_response": "Failed to receive response from Home Assistant for request",
+ "failed_sign": "Could not sign Home Assistant URL",
"invalid_keys": "Invalid keys",
"unknown": "Unknown error",
"troubleshooting": "Check troubleshooting",
diff --git a/src/localize/languages/pt_br.json b/src/localize/languages/pt_br.json
index 98b3b497..5d29b6d4 100644
--- a/src/localize/languages/pt_br.json
+++ b/src/localize/languages/pt_br.json
@@ -52,7 +52,7 @@
"event_gallery": {
"min_columns": "Número mínimo de colunas"
},
- "event_viewer": {
+ "media_viewer": {
"auto_play": "Reproduzir mídia automaticamente",
"auto_unmute": "Ativar mídia automaticamente",
"draggable": "Visualizador de eventos pode ser arrastado/deslizado",
@@ -188,8 +188,8 @@
"live_secondary": "Opções de visualização da câmera ao vivo",
"event_gallery": "Galeria de eventos",
"event_gallery_secondary": "Opções da galeria de Snapshots e clipes",
- "event_viewer": "Visualizador de eventos",
- "event_viewer_secondary": "Opções do visualizador de Snapshots e clipes",
+ "media_viewer": "Visualizador de eventos",
+ "media_viewer_secondary": "Opções do visualizador de Snapshots e clipes",
"image": "Imagem",
"image_secondary": "Opções de visualização de imagem estática",
"dimensions": "Dimensões",
diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts
index 0d41744f..516f4a2c 100644
--- a/src/patches/ha-camera-stream.ts
+++ b/src/patches/ha-camera-stream.ts
@@ -77,6 +77,13 @@ customElements.whenDefined('ha-camera-stream').then(() => {
this._player?.unmute();
}
+ /**
+ * Seek the video (unsupported).
+ */
+ public seek(seconds: number): void {
+ this._player?.seek(seconds);
+ }
+
/**
* Master render method.
* @returns A rendered template.
diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts
index 693acfb3..92b24c1e 100644
--- a/src/patches/ha-hls-player.ts
+++ b/src/patches/ha-hls-player.ts
@@ -59,6 +59,15 @@ customElements.whenDefined('ha-hls-player').then(() => {
}
}
+ /**
+ * Seek the video.
+ */
+ public seek(seconds: number): void {
+ if (this._video) {
+ this._video.currentTime = seconds;
+ }
+ }
+
// =====================================================================================
// Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts
index ecafbc30..74283264 100644
--- a/src/patches/ha-web-rtc-player.ts
+++ b/src/patches/ha-web-rtc-player.ts
@@ -59,6 +59,15 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
}
}
+ /**
+ * Seek the video.
+ */
+ public seek(seconds: number): void {
+ if (this._video) {
+ this._video.currentTime = seconds;
+ }
+ }
+
// =====================================================================================
// Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
diff --git a/src/scss/carousel.scss b/src/scss/carousel.scss
index 32dc9147..d72a6def 100644
--- a/src/scss/carousel.scss
+++ b/src/scss/carousel.scss
@@ -55,7 +55,6 @@ img,video {
.embla__slide {
position: relative;
- height: 100%;
overflow: visible;
}
:host([direction=vertical]) .embla__slide {
diff --git a/src/scss/const.scss b/src/scss/const.scss
index 38cc2113..8c612c78 100644
--- a/src/scss/const.scss
+++ b/src/scss/const.scss
@@ -1,5 +1,4 @@
:host {
- --frigate-card-thumbnail-size: 100px;
--frigate-card-thumbnail-size-max: 175px;
--frigate-card-thumbnail-details-width: calc(var(--frigate-card-thumbnail-size) + 200px);
}
\ No newline at end of file
diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss
index 2777fd32..09c63520 100644
--- a/src/scss/gallery.scss
+++ b/src/scss/gallery.scss
@@ -1,8 +1,6 @@
-@use "const.scss";
-
:host {
width: 100%;
- height: 100%;
+ height: auto;
overflow: auto;
// Hide scrollbar: IE and Edge
@@ -16,8 +14,8 @@
display: grid;
grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr));
- grid-auto-rows: var(--frigate-card-thumbnail-size);
- grid-column-gap: var(--frigate-card-gallery-gap);
+ grid-auto-rows: 1fr;
+ gap: var(--frigate-card-gallery-gap);
}
// Hide scrollbar for Chrome, Safari and Opera
@@ -32,16 +30,21 @@ ha-card {
box-sizing: border-box;
text-align: center;
opacity: 0.7;
- color: var(--secondary-text-color, white);
- border: 2px ridge var(--secondary-text-color, black);
+ color: var(--primary-text-color, white);
+ border: 2px ridge var(--primary-text-color, black);
border-radius: 5px;
background-color: var(--primary-background-color, black);
padding: 10px;
line-height: 1;
overflow: hidden;
+
+ transition: transform 0.2s linear;
+}
+ha-card:hover {
+ transform: scale(1.04)
}
-:host(:not([details])) ha-card,
-:host(:not([details])) frigate-card-thumbnail {
- aspect-ratio: 1 / 1;
-}
+ha-card, frigate-card-thumbnail {
+ height: 100%;
+ max-height: var(--frigate-card-thumbnail-size);
+}
\ No newline at end of file
diff --git a/src/scss/media-carousel.scss b/src/scss/media-carousel.scss
index 5611628f..d0d0142d 100644
--- a/src/scss/media-carousel.scss
+++ b/src/scss/media-carousel.scss
@@ -4,4 +4,5 @@
.embla__slide {
flex: 0 0 100%;
+ height: 100%;
}
\ No newline at end of file
diff --git a/src/scss/message.scss b/src/scss/message.scss
index 384525e5..696f2507 100644
--- a/src/scss/message.scss
+++ b/src/scss/message.scss
@@ -2,6 +2,11 @@
height: 100%;
width: 100%;
display: block;
+
+ // Ensure error messages are selectable.
+ user-select: text;
+ // Safari only has prefixed support.
+ -webkit-user-select: text;
}
div.wrapper {
@@ -20,6 +25,7 @@ div.message {
div.message div.contents {
padding: 10px;
height: 100%;
+ max-width: 100%;
}
div.message div.icon {
@@ -37,4 +43,6 @@ div.message div.icon {
.message pre {
margin-top: 20px;
+ white-space: pre-wrap;
+ word-break: break-all;
}
diff --git a/src/scss/next-previous-control.scss b/src/scss/next-previous-control.scss
index c031ffbb..d72d61bd 100644
--- a/src/scss/next-previous-control.scss
+++ b/src/scss/next-previous-control.scss
@@ -32,6 +32,7 @@
box-shadow: 0px 0px 20px 5px black;
transition: all 0.2s ease-out;
opacity: 0.8;
+ aspect-ratio: 1 / 1;
}
.controls.thumbnails:hover {
opacity: 1 !important;
diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss
index 033d09fd..71f70170 100644
--- a/src/scss/thumbnail-carousel.scss
+++ b/src/scss/thumbnail-carousel.scss
@@ -1,13 +1,13 @@
-@use "const.scss";
+@use 'const.scss';
:host {
- --frigate-card-carousel-thumbnail-opacity: 1.0;
+ --frigate-card-carousel-thumbnail-opacity: 1;
}
-:host([direction=vertical]) {
+:host([direction='vertical']) {
height: 100%;
}
-:host([direction=horizontal]) {
+:host([direction='horizontal']) {
// In fullscreen mode, without explicitly setting the height to auto Chrome
// will construct a stylesheet with 100% height.
height: auto;
@@ -19,14 +19,13 @@
transition: opacity 0.6s ease;
}
.embla__slide.slide-selected {
- opacity: 1.0;
+ opacity: 1;
}
-frigate-card-thumbnail[details] {
- width: var(--frigate-card-thumbnail-details-width);
- height: var(--frigate-card-thumbnail-size);
-}
-frigate-card-thumbnail:not([details]) {
+frigate-card-thumbnail {
width: var(--frigate-card-thumbnail-size);
height: var(--frigate-card-thumbnail-size);
-}
\ No newline at end of file
+}
+frigate-card-thumbnail[details] {
+ width: var(--frigate-card-thumbnail-details-width);
+}
diff --git a/src/scss/thumbnail-details.scss b/src/scss/thumbnail-details.scss
index df77a6c2..f8ea64e7 100644
--- a/src/scss/thumbnail-details.scss
+++ b/src/scss/thumbnail-details.scss
@@ -8,11 +8,15 @@
column-gap: 5%;
}
-div.right, div.left {
+div.right,
+div.left {
display: flex;
flex-direction: column;
justify-content: center;
}
+div.right {
+ align-items: center;
+}
div.left {
flex: 1;
@@ -36,6 +40,7 @@ span.heading {
font-weight: bold;
}
-.larger {
+div.larger,
+span.larger {
font-size: 1.5rem;
}
diff --git a/src/scss/thumbnail-feature-event.scss b/src/scss/thumbnail-feature-event.scss
new file mode 100644
index 00000000..87e0a836
--- /dev/null
+++ b/src/scss/thumbnail-feature-event.scss
@@ -0,0 +1,37 @@
+:host {
+ display: block;
+ max-width: var(--frigate-card-thumbnail-size);
+ max-height: var(--frigate-card-thumbnail-size);
+ overflow: hidden;
+
+ // Restrict images to a maximum of thumbnail size.
+ aspect-ratio: 1 / 1;
+}
+
+img, ha-icon {
+ border-radius: var(--ha-card-border-radius, 4px);
+
+ width: 100%;
+ height: 100%;
+
+ // Not 'contain' as some thumbnails may vary in aspect-ratio slightly and
+ // should be clipped to fill the thumbnail div whilst maintaining
+ // aspect-ratio.
+ object-fit: cover;
+
+ transition: transform 0.2s linear;
+}
+
+ha-icon {
+ --mdc-icon-size: 50%;
+ color: var(--primary-text-color);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border: 1px solid rgba(255,255,255,0.3);
+ box-sizing: border-box;
+}
+
+img:hover {
+ transform: scale(1.04);
+}
\ No newline at end of file
diff --git a/src/scss/thumbnail-feature-recording.scss b/src/scss/thumbnail-feature-recording.scss
new file mode 100644
index 00000000..a165b971
--- /dev/null
+++ b/src/scss/thumbnail-feature-recording.scss
@@ -0,0 +1,29 @@
+:host {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+
+ aspect-ratio: 1 / 1;
+ overflow: hidden;
+ max-width: var(--frigate-card-thumbnail-size);
+ max-height: var(--frigate-card-thumbnail-size);
+ padding: 10px;
+
+ border: 1px solid var(--secondary-color);
+ background-color: var(--secondary-background-color);
+ border-radius: var(--ha-card-border-radius, 4px);
+ box-sizing: border-box;
+
+ color: var(--primary-text-color);
+
+ transition: transform 0.2s linear;
+}
+
+:host(:hover) {
+ transform: scale(1.04);
+}
+
+div.title {
+ font-size: 1.5rem;
+}
diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss
index 5eaf92c4..35a443a8 100644
--- a/src/scss/thumbnail.scss
+++ b/src/scss/thumbnail.scss
@@ -1,5 +1,3 @@
-@use "const.scss";
-
:host {
display: flex;
flex-direction: row;
@@ -7,6 +5,11 @@
// Ensure control icons are relative to the thumbnail.
position: relative;
+ overflow: hidden;
+}
+
+:host(:not([details])) {
+ aspect-ratio: 1 / 1;
}
:host([details]) {
@@ -19,39 +22,21 @@
background-color: var(--primary-background-color, black);
}
-img {
- border-radius: var(--ha-card-border-radius, 4px);
-
- // Not 'contain' as some thumbnails may vary in aspect-ratio slightly and
- // should be clipped to fill the thumbnail div whilst maintaining
- // aspect-ratio.
- object-fit: cover;
-
- // Restrict images to a maximum of thumbnail size.
- aspect-ratio: 1 / 1;
- height: 100%;
-
- max-width: var(--frigate-card-thumbnail-size-max);
- max-height: var(--frigate-card-thumbnail-size-max);
-
- transition: transform 0.2s linear;
-}
-img:hover {
- transform: scale(1.04);
+ha-icon {
+ position: absolute;
+ border-radius: 50%;
}
ha-icon.favorite {
- position: absolute;
color: gold;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 50%;
opacity: 0.8;
}
ha-icon.timeline {
- position: absolute;
color: var(--primary-color);
right: 0px;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 50%;
}
+
+frigate-card-thumbnail-details-event, frigate-card-thumbnail-details-recording {
+ flex: 1;
+}
\ No newline at end of file
diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss
index 479c7175..84c4cab0 100644
--- a/src/scss/timeline-core.scss
+++ b/src/scss/timeline-core.scss
@@ -1,5 +1,6 @@
@use 'vis-timeline/dist/vis-timeline-graph2d.css';
@use 'drawer';
+@use 'const.scss';
:host {
width: 100%;
@@ -15,6 +16,14 @@
position: relative;
}
+frigate-card-thumbnail {
+ height: var(--frigate-card-thumbnail-size);
+ width: var(--frigate-card-thumbnail-size);
+}
+frigate-card-thumbnail[details] {
+ width: var(--frigate-card-thumbnail-details-width);
+}
+
div.timeline {
flex: 1;
}
@@ -46,10 +55,19 @@ div.timeline.right-margin {
color: var(--primary-text-color);
background-color: var(--primary-color);
}
+.vis-item.vis-background {
+ background-color: var(--primary-color);
+ opacity: 0.1;
+}
-.vis-item:hover {
- // Float icons upwards when the user hovers over them.
- z-index: 2;
+.vis-item:not(.vis-background) {
+ cursor: pointer;
+}
+.vis-item.vis-background, .vis-labelset, .vis-time-axis {
+ cursor: crosshair;
+}
+.vis-item:active {
+ cursor: unset;
}
.vis-item.vis-box {
@@ -71,6 +89,14 @@ div.timeline.right-margin {
border-color: var(--secondary-color);
}
+// Give an indication that the user can interact with the axes.
+.vis-text.vis-minor, .vis-label {
+ transition: background-color 0.5s ease-out;
+}
+.vis-text.vis-minor:hover, .vis-label:hover {
+ background-color: var(--primary-color);
+}
+
.vis-time-axis .vis-grid.vis-major {
border-color: var(--secondary-color);
}
diff --git a/src/types.ts b/src/types.ts
index d01a00e1..0b429c85 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -48,8 +48,8 @@ export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
const FRIGATE_CARD_VIEWS = [
...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
- // Event: A clip or snapshot (timeline may produce mixed media lists).
- 'event',
+ // Media: A generic piece of media (could be clip, snapshot, recording).
+ 'media',
] as const;
export type FrigateCardView = typeof FRIGATE_CARD_VIEWS[number];
@@ -83,7 +83,14 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [
export type AutoUnmuteCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number];
export type AutoPlayCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number];
-export class FrigateCardError extends Error {}
+export class FrigateCardError extends Error {
+ context?: unknown;
+
+ constructor(message: string, context?: unknown) {
+ super(message);
+ this.context = context;
+ }
+}
/**
* Action Types (for "Picture Elements" / Menu)
@@ -400,7 +407,10 @@ const cameraConfigSchema = z
trigger_by_motion: z.boolean().default(cameraConfigDefault.trigger_by_motion),
trigger_by_occupancy: z.boolean().default(cameraConfigDefault.trigger_by_occupancy),
- trigger_by_entities: z.string().array().default(cameraConfigDefault.trigger_by_entities),
+ trigger_by_entities: z
+ .string()
+ .array()
+ .default(cameraConfigDefault.trigger_by_entities),
})
.default(cameraConfigDefault);
export type CameraConfig = z.infer;
@@ -508,7 +518,7 @@ const viewConfigDefault = {
scan: {
enabled: false,
show_trigger_status: true,
- }
+ },
};
const viewConfigSchema = z
.object({
@@ -525,10 +535,14 @@ const viewConfigSchema = z
update_entities: z.string().array().optional(),
render_entities: z.string().array().optional(),
dark_mode: z.enum(['on', 'off', 'auto']).optional(),
- scan: z.object({
- enabled: z.boolean().default(viewConfigDefault.scan.enabled),
- show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status),
- }).default(viewConfigDefault.scan)
+ scan: z
+ .object({
+ enabled: z.boolean().default(viewConfigDefault.scan.enabled),
+ show_trigger_status: z
+ .boolean()
+ .default(viewConfigDefault.scan.show_trigger_status),
+ })
+ .default(viewConfigDefault.scan),
})
.merge(actionsSchema)
.default(viewConfigDefault);
@@ -970,6 +984,7 @@ const timelineConfigDefault = {
clustering_threshold: 3,
media: 'all' as const,
window_seconds: 60 * 60,
+ show_recordings: true,
controls: {
thumbnails: {
mode: 'left' as const,
@@ -995,6 +1010,7 @@ const timelineConfigSchema = z
.max(24 * 60 * 60)
.optional()
.default(timelineConfigDefault.window_seconds),
+ show_recordings: z.boolean().optional().default(timelineConfigDefault.show_recordings),
controls: z
.object({
thumbnails: thumbnailsControlSchema
@@ -1059,7 +1075,7 @@ export const frigateCardConfigSchema = z.object({
view: viewConfigSchema,
menu: menuConfigSchema,
live: liveConfigSchema,
- event_viewer: viewerConfigSchema,
+ media_viewer: viewerConfigSchema,
event_gallery: galleryConfigSchema,
image: imageConfigSchema,
elements: pictureElementsSchema,
@@ -1085,7 +1101,7 @@ export const frigateCardConfigDefaults = {
view: viewConfigDefault,
menu: menuConfigDefault,
live: liveConfigDefault,
- event_viewer: viewerConfigDefault,
+ media_viewer: viewerConfigDefault,
event_gallery: galleryConfigDefault,
image: imageConfigDefault,
timeline: timelineConfigDefault,
@@ -1130,6 +1146,15 @@ export interface BrowseMediaQueryParameters {
cameraID?: string;
}
+export interface BrowseRecordingQueryParameters {
+ clientId: string;
+ cameraName: string;
+ year: number;
+ month: number;
+ day: number;
+ hour: number;
+}
+
export interface BrowseMediaNeighbors {
previous: FrigateBrowseMediaSource | null;
previousIndex: number | null;
@@ -1165,6 +1190,7 @@ export interface FrigateCardMediaPlayer {
pause(): void;
mute(): void;
unmute(): void;
+ seek(seconds: number): void;
}
export interface CardHelpers {
@@ -1180,7 +1206,9 @@ export interface CardHelpers {
*/
export const MEDIA_CLASS_PLAYLIST = 'playlist' as const;
+export const MEDIA_CLASS_VIDEO = 'video' as const;
export const MEDIA_TYPE_PLAYLIST = 'playlist' as const;
+export const MEDIA_TYPE_VIDEO = 'video' as const;
// Recursive type, cannot use type interference:
// See: https://github.com/colinhacks/zod#recursive-types
@@ -1212,10 +1240,24 @@ export interface FrigateEvent {
retain_indefinitely?: boolean;
}
+export interface FrigateRecording {
+ camera: string;
+ start_time: number;
+ end_time: number;
+ events: number;
+
+ // Specifies the point at which this recording should be played, the
+ // seek_time is the date of the desired play point, and seek_seconds is the
+ // number of seconds to seek to reach that point.
+ seek_time?: number;
+ seek_seconds?: number;
+}
+
export interface FrigateBrowseMediaSource extends BrowseMediaSource {
children?: FrigateBrowseMediaSource[] | null;
frigate?: {
- event: FrigateEvent;
+ event?: FrigateEvent;
+ recording?: FrigateRecording;
};
}
@@ -1274,7 +1316,7 @@ export type Entity = z.infer;
export const extendedEntitySchema = entitySchema.extend({
// Extended entity results.
unique_id: z.string().optional(),
-})
+});
export type ExtendedEntity = z.infer;
export const entityListSchema = entitySchema.array();
diff --git a/src/utils/frigate.ts b/src/utils/frigate.ts
new file mode 100644
index 00000000..8054605f
--- /dev/null
+++ b/src/utils/frigate.ts
@@ -0,0 +1,76 @@
+import { z } from 'zod';
+import { ExtendedHomeAssistant } from '../types';
+import { homeAssistantHTTPRequest } from './ha';
+
+const recordingSummaryHourSchema = z.object({
+ hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)),
+ duration: z.number().min(0),
+ events: z.number().min(0),
+});
+
+const recordingSummarySchema = z
+ .object({
+ day: z.preprocess((arg) => {
+ // Must provide the hour:minute:second on parsing or Javascript will
+ // assume UTC midnight.
+ return typeof arg === 'string' ? new Date(`${arg} 00:00:00`) : arg;
+ }, z.date()),
+ events: z.number(),
+ hours: recordingSummaryHourSchema.array(),
+ })
+ .array();
+export type RecordingSummary = z.infer;
+
+const recordingSegmentSchema = z.object({
+ start_time: z.number(),
+ end_time: z.number(),
+ id: z.string(),
+});
+const recordingSegmentsSchema = recordingSegmentSchema.array();
+export type RecordingSegments = z.infer;
+
+/**
+ * Get the recordings summary.
+ * @param hass The Home Assistant object.
+ * @param client_id The Frigate client_id.
+ * @param camera_name The Frigate camera name.
+ * @returns A RecordingSummary object.
+ */
+export const getRecordingsSummary = async (
+ hass: ExtendedHomeAssistant,
+ client_id: string,
+ camera_name: string,
+): Promise => {
+ return await homeAssistantHTTPRequest(
+ hass,
+ recordingSummarySchema,
+ `/api/frigate/${client_id}/${camera_name}/recordings/summary`,
+ );
+};
+
+/**
+ * Get the recording segments..
+ * @param hass The Home Assistant object.
+ * @param client_id The Frigate client_id.
+ * @param camera_name The Frigate camera name.
+ * @param before The segment low watermark.
+ * @param after The segment high watermark.
+ * @returns A RecordingSegments object.
+ */
+export const getRecordingSegments = async (
+ hass: ExtendedHomeAssistant,
+ client_id: string,
+ camera_name: string,
+ before: Date,
+ after: Date,
+): Promise => {
+ return await homeAssistantHTTPRequest(
+ hass,
+ recordingSegmentsSchema,
+ `/api/frigate/${client_id}/${camera_name}/recordings`,
+ new URLSearchParams({
+ before: String(before.getTime() / 1000),
+ after: String(after.getTime() / 1000),
+ }),
+ );
+};
diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts
index 743fe125..5bb03d4f 100644
--- a/src/utils/ha/browse-media.ts
+++ b/src/utils/ha/browse-media.ts
@@ -8,15 +8,23 @@ import {
import { homeAssistantWSRequest } from '.';
import {
dispatchErrorMessageEvent,
+ dispatchFrigateCardErrorEvent,
dispatchMessageEvent
} from '../../components/message.js';
import { localize } from '../../localize/localize.js';
import {
BrowseMediaQueryParameters,
+ BrowseRecordingQueryParameters,
CameraConfig,
FrigateBrowseMediaSource,
- frigateBrowseMediaSourceSchema, FrigateEvent, MEDIA_CLASS_PLAYLIST,
- MEDIA_TYPE_PLAYLIST
+ frigateBrowseMediaSourceSchema,
+ FrigateCardError,
+ FrigateEvent,
+ FrigateRecording,
+ MEDIA_CLASS_PLAYLIST,
+ MEDIA_CLASS_VIDEO,
+ MEDIA_TYPE_PLAYLIST,
+ MEDIA_TYPE_VIDEO
} from '../../types.js';
import { View } from '../../view.js';
import { getCameraTitle } from '../camera.js';
@@ -27,7 +35,7 @@ import { getCameraTitle } from '../camera.js';
* @returns The `event_id` or `null` if not successfully parsed.
*/
export const getEventID = (media: FrigateBrowseMediaSource): string | null => {
- return media.frigate?.event.id ?? null;
+ return media.frigate?.event?.id ?? null;
};
/**
@@ -36,7 +44,7 @@ export const getEventID = (media: FrigateBrowseMediaSource): string | null => {
* @returns The start time in unix/epoch time, or null if it cannot be determined.
*/
export const getEventStartTime = (media: FrigateBrowseMediaSource): number | null => {
- return media.frigate?.event.start_time ?? null;
+ return media.frigate?.event?.start_time ?? null;
};
/**
@@ -336,7 +344,7 @@ export const fetchLatestMediaAndDispatchViewChange = async (
try {
parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters);
} catch (e) {
- return dispatchErrorMessageEvent(element, (e as Error).message);
+ return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
}
const childIndex = getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) {
@@ -376,7 +384,7 @@ export const fetchChildMediaAndDispatchViewChange = async (
try {
parent = await browseMedia(hass, child.media_content_id);
} catch (e) {
- return dispatchErrorMessageEvent(element, (e as Error).message);
+ return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
}
view
@@ -409,6 +417,38 @@ export const createEventParentForChildren = (
};
};
+/**
+ * Given a media video child with a given media_content_id.
+ * @param title The title to use for the child.
+ * @param media_con
+ * @param children The children media items.
+ * @returns A single parent containing the children.
+ */
+export const createVideoChild = (
+ title: string,
+ mediaContentID: string,
+ options?: {
+ thumbnail?: string;
+ recording?: FrigateRecording;
+ },
+): FrigateBrowseMediaSource => {
+ return {
+ title: title,
+ media_class: MEDIA_CLASS_VIDEO,
+ media_content_type: MEDIA_TYPE_VIDEO,
+ media_content_id: mediaContentID,
+ can_play: true,
+ can_expand: false,
+ thumbnail: options?.thumbnail ?? null,
+ children: null,
+ ...(options?.recording && {
+ frigate: {
+ recording: options.recording,
+ },
+ }),
+ };
+};
+
/**
* Convenience function to convert a timestamp to hours, minutes and seconds
* string. Heavily inspired by, and returning the same format as, the Frigate
@@ -436,3 +476,23 @@ export function getEventDurationString(event: FrigateEvent): string {
duration += `${seconds}s`;
return duration;
}
+
+/**
+ * Generate a recording identifier.
+ * @param hass The HomeAssistant object.
+ * @param params The recording parameters to use in the identifer.
+ * @returns A recording identifier.
+ */
+export const generateRecordingIdentifier = (
+ params: BrowseRecordingQueryParameters,
+): string => {
+ return [
+ 'media-source://frigate',
+ params.clientId,
+ 'recordings',
+ `${params.year}-${String(params.month).padStart(2, '0')}`,
+ String(params.day).padStart(2, '0'),
+ String(params.hour).padStart(2, '0'),
+ params.cameraName,
+ ].join('/');
+};
diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts
index db7b3448..187aed60 100644
--- a/src/utils/ha/index.ts
+++ b/src/utils/ha/index.ts
@@ -4,7 +4,9 @@ import { StyleInfo } from 'lit/directives/style-map.js';
import { ZodSchema } from 'zod';
import { localize } from '../../localize/localize.js';
import {
- CardHelpers, ExtendedHomeAssistant,
+ CardHelpers,
+ ExtendedHomeAssistant,
+ FrigateCardError,
SignedPath,
signedPathSchema,
StateParameters
@@ -36,12 +38,16 @@ export async function homeAssistantWSRequest(
const parseResult = schema.safeParse(response);
if (!parseResult.success) {
const keys = getParseErrorKeys(parseResult.error);
- const error_message =
- `${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` +
- localize('error.invalid_keys') +
- `: '${keys}'`;
- console.warn(error_message);
- throw new Error(error_message);
+ const error_message = localize('error.invalid_response');
+ console.warn(
+ `${error_message}: ${JSON.stringify(request)}. ${localize(
+ 'error.invalid_keys',
+ )}: ${keys}`,
+ );
+ throw new FrigateCardError(error_message, {
+ request: request,
+ invalid_keys: keys,
+ });
}
return parseResult.data;
}
@@ -74,6 +80,70 @@ export async function homeAssistantSignPath(
return hass.hassUrl(response.path);
}
+/**
+ * Make a HomeAssistant HTTP request. May throw.
+ * @param hass The HomeAssistant object to send the request with.
+ * @param schema The expected Zod schema of the response.
+ * @param request The request to make.
+ * @returns The parsed valid response or null on malformed.
+ */
+export async function homeAssistantHTTPRequest(
+ hass: ExtendedHomeAssistant,
+ schema: ZodSchema,
+ url: string,
+ params?: URLSearchParams,
+): Promise {
+ let signResponse: string | null | undefined;
+ try {
+ signResponse = await homeAssistantSignPath(hass, url);
+ } catch (e) {
+ console.warn(e);
+ }
+
+ if (!signResponse) {
+ throw new FrigateCardError(localize('error.failed_sign'), {
+ url: url.toString(),
+ });
+ }
+
+ const signedURL = new URL(signResponse);
+
+ if (params) {
+ for (const [key, value] of params.entries()) {
+ signedURL.searchParams.append(key, value);
+ }
+ }
+
+ const response = await fetch(signedURL.toString());
+ if (!response.ok) {
+ throw new FrigateCardError(localize('error.failed_response'), {
+ url: signedURL.toString(),
+ status: response.status,
+ statusText: response.statusText,
+ });
+ }
+
+ let raw_json;
+ try {
+ raw_json = await response.json();
+ } catch (e) {
+ console.warn(e);
+ throw new FrigateCardError(localize('error.undecodable_response'), {
+ url: signedURL.toString(),
+ });
+ }
+
+ try {
+ return schema.parse(raw_json);
+ } catch (e) {
+ console.warn(e);
+ throw new FrigateCardError(localize('error.invalid_response'), {
+ url: signedURL.toString(),
+ response: raw_json,
+ });
+ }
+}
+
interface HassStateDifference {
entity: string;
oldState?: HassEntity;
@@ -308,9 +378,9 @@ export const isTriggeredState = (state?: HassEntity): boolean => {
/**
* Get entities from the HASS object.
- * @param hass
- * @param domain
- * @returns
+ * @param hass
+ * @param domain
+ * @returns A list of entities ids.
*/
export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => {
if (!hass) {
@@ -321,4 +391,4 @@ export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): strin
);
entities.sort();
return entities;
-}
+};
diff --git a/src/view.ts b/src/view.ts
index 86bcda83..9b10e22c 100644
--- a/src/view.ts
+++ b/src/view.ts
@@ -102,7 +102,7 @@ export class View {
* Determine if a view is for the media viewer.
*/
public isViewerView(): boolean {
- return ['clip', 'snapshot', 'event'].includes(this.view);
+ return ['clip', 'snapshot', 'media'].includes(this.view);
}
/**
@@ -145,9 +145,9 @@ export class View {
/**
* Dispatch an event to request a view change.
- * @param node The element dispatching the event.
+ * @param target The target dispatching the event.
*/
- public dispatchChangeEvent(node: HTMLElement): void {
- dispatchFrigateCardEvent(node, 'change-view', this);
+ public dispatchChangeEvent(target: EventTarget): void {
+ dispatchFrigateCardEvent(target, 'change-view', this);
}
}