Allow live thumbnails to load recordings .
This commit is contained in:
@@ -564,7 +564,8 @@ live:
|
||||
| `show_download_control` | `true` | :white_check_mark: | Whether to show the download control on each thumbnail.|
|
||||
| `show_favorite_control` | `true` | :white_check_mark: | Whether to show the favorite ('star') control on each thumbnail.|
|
||||
| `show_timeline_control` | `true` | :white_check_mark: | Whether to show the timeline ('target') control on each thumbnail.|
|
||||
| `media` | `all` | :white_check_mark: | Whether to show `clips`, `snapshots` or `all` in the thumbnail carousel in the `live` view.|
|
||||
| `media_type` | `events` | :white_check_mark: | Whether to load `events` or `recordings` media.|
|
||||
| `events_media_type` | `all` | :white_check_mark: | Whether to show `clips`, `snapshots` or `all` in the thumbnail carousel in the `live` view. This setting is only relevant when the `media_type` parameter (above) is set to `events`.|
|
||||
|
||||
#### Live Controls: Next / Previous
|
||||
|
||||
@@ -597,7 +598,7 @@ live:
|
||||
| `style` | `ribbon` | :white_check_mark: | Whether the timeline should show events as a single flat `ribbon` or a `stack` of events that are clustered using the `clustering_threshold` (below). |
|
||||
| `window_seconds` | `3600` | :white_check_mark: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. |
|
||||
| `clustering_threshold` | `3` | :white_check_mark: | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. |
|
||||
| `media` | `all` | :white_check_mark: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.|
|
||||
| `events_media_type` | `all` | :white_check_mark: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.|
|
||||
| `show_recordings` | `true` | :white_check_mark: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).|
|
||||
|
||||
**Caution**: 🚩 For optimal UX, keep the settings for the mini-timeline in the `live` and `media_viewer` identical. Dragging the timeline may cause the card to change between the `live` view and `media_viewer` based views as the user pans between the past and present -- if the settings are different the timeline must "reset".
|
||||
@@ -781,7 +782,7 @@ media_viewer:
|
||||
| `style` | `ribbon` | :heavy_multiplication_x: | Whether the timeline should show events as a single flat `ribbon` or a `stack` of events that are clustered using the `clustering_threshold` (below). |
|
||||
| `window_seconds` | `3600` | :heavy_multiplication_x: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. |
|
||||
| `clustering_threshold` | `3` | :heavy_multiplication_x: | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. |
|
||||
| `media` | `all` | :heavy_multiplication_x: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.|
|
||||
| `events_media_type` | `all` | :heavy_multiplication_x: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot. |
|
||||
| `show_recordings` | `true` | :heavy_multiplication_x: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).|
|
||||
|
||||
**Caution**: 🚩 For optimal UX, keep the settings for the mini-timeline in the `live` and `media_viewer` identical. Dragging the timeline may cause the card to change between the `live` view and `media_viewer` based views as the user pans between the past and present -- if the settings are different the timeline must "reset".
|
||||
@@ -911,7 +912,7 @@ See the [fully expanded timeline configuration example](#config-expanded-timelin
|
||||
| `style` | `stack` | :heavy_multiplication_x: | Whether the timeline should show events as a single flat `ribbon` or a `stack` of events that are clustered using the `clustering_threshold` (below). |
|
||||
| `window_seconds` | `3600` | :heavy_multiplication_x: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. |
|
||||
| `clustering_threshold` | `3` | :heavy_multiplication_x: | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. |
|
||||
| `media` | `all` | :heavy_multiplication_x: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.|
|
||||
| `events_media_type` | `all` | :heavy_multiplication_x: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.|
|
||||
| `show_recordings` | `true` | :heavy_multiplication_x: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).|
|
||||
| `controls` | | :heavy_multiplication_x: | Configuration for the timeline controls. See below.|
|
||||
|
||||
@@ -2092,7 +2093,8 @@ live:
|
||||
style: chevrons
|
||||
size: 48
|
||||
thumbnails:
|
||||
media: clips
|
||||
media_type: events
|
||||
events_media_type: all
|
||||
size: 100
|
||||
show_details: false
|
||||
show_download_control: true
|
||||
@@ -2103,7 +2105,7 @@ live:
|
||||
style: ribbon
|
||||
mode: none
|
||||
clustering_threshold: 3
|
||||
media: all
|
||||
events_media_type: all
|
||||
show_recordings: true
|
||||
window_seconds: 3600
|
||||
title:
|
||||
@@ -2201,7 +2203,7 @@ media_viewer:
|
||||
style: ribbon
|
||||
mode: none
|
||||
clustering_threshold: 3
|
||||
media: all
|
||||
events_media_type: all
|
||||
show_recordings: true
|
||||
window_seconds: 3600
|
||||
title:
|
||||
@@ -2663,7 +2665,7 @@ Reference: [Timeline Options](#timeline-options).
|
||||
timeline:
|
||||
style: stack
|
||||
clustering_threshold: 3
|
||||
media: all
|
||||
events_media_type: all
|
||||
show_recordings: true
|
||||
window_seconds: 3600
|
||||
controls:
|
||||
@@ -2755,8 +2757,8 @@ overrides:
|
||||
maxAudioLag: 10
|
||||
videoBufferSize: 524288
|
||||
audioBufferSize: 131072
|
||||
image:
|
||||
refresh_seconds: 1
|
||||
image:
|
||||
refresh_seconds: 1
|
||||
live:
|
||||
webrtc_card:
|
||||
ui: true
|
||||
@@ -2779,7 +2781,8 @@ overrides:
|
||||
style: chevrons
|
||||
size: 48
|
||||
thumbnails:
|
||||
media: clips
|
||||
media_type: events
|
||||
events_media_type: all
|
||||
size: 100
|
||||
show_details: false
|
||||
show_download_control: true
|
||||
|
||||
@@ -47,18 +47,18 @@ export class TimelineDataSource {
|
||||
protected _eventRanges = new ExpiringMemoryRangeSet();
|
||||
|
||||
protected _cameraIDs: Set<string>;
|
||||
protected _mediaType: ClipsOrSnapshotsOrAll;
|
||||
protected _eventsMediaType: ClipsOrSnapshotsOrAll;
|
||||
protected _showRecordings: boolean;
|
||||
|
||||
constructor(
|
||||
cameraManager: CameraManager,
|
||||
cameraIDs: Set<string>,
|
||||
media: ClipsOrSnapshotsOrAll,
|
||||
eventsMediaType: ClipsOrSnapshotsOrAll,
|
||||
showRecordings: boolean,
|
||||
) {
|
||||
this._cameraManager = cameraManager;
|
||||
this._cameraIDs = cameraIDs;
|
||||
this._mediaType = media;
|
||||
this._eventsMediaType = eventsMediaType;
|
||||
this._showRecordings = showRecordings;
|
||||
}
|
||||
|
||||
@@ -101,8 +101,8 @@ export class TimelineDataSource {
|
||||
return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, {
|
||||
start: window.start,
|
||||
end: window.end,
|
||||
...(this._mediaType === 'clips' && { hasClip: true }),
|
||||
...(this._mediaType === 'snapshots' && { hasSnapshot: true }),
|
||||
...(this._eventsMediaType === 'clips' && { hasClip: true }),
|
||||
...(this._eventsMediaType === 'snapshots' && { hasSnapshot: true }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export class FrigateCardGallery extends LitElement {
|
||||
this.view,
|
||||
);
|
||||
} else {
|
||||
const mediaType = this.view.is('snapshots')
|
||||
const eventsMediaType = this.view.is('snapshots')
|
||||
? 'snapshots'
|
||||
: this.view.is('clips')
|
||||
? 'clips'
|
||||
@@ -98,7 +98,7 @@ export class FrigateCardGallery extends LitElement {
|
||||
this.cardWideConfig,
|
||||
this.view,
|
||||
{
|
||||
...(mediaType && { mediaType: mediaType }),
|
||||
...(eventsMediaType && { eventsMediaType: eventsMediaType }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+42
-16
@@ -14,9 +14,16 @@ import {
|
||||
ThumbnailsControlConfig,
|
||||
} from '../config/types.js';
|
||||
import basicBlockStyle from '../scss/basic-block.scss';
|
||||
import { ClipsOrSnapshotsOrAll, ExtendedHomeAssistant } from '../types.js';
|
||||
import {
|
||||
ClipsOrSnapshotsOrAll,
|
||||
EventsOrRecordings,
|
||||
ExtendedHomeAssistant,
|
||||
} from '../types.js';
|
||||
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view';
|
||||
import {
|
||||
changeViewToRecentEventsForCameraAndDependents,
|
||||
changeViewToRecentRecordingForCameraAndDependents,
|
||||
} from '../utils/media-to-view';
|
||||
import { View } from '../view/view.js';
|
||||
import './surround-basic.js';
|
||||
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||
@@ -48,7 +55,10 @@ export class FrigateCardSurround extends LitElement {
|
||||
|
||||
// If fetchMedia is not specified, no fetching is done.
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public fetchMedia?: ClipsOrSnapshotsOrAll;
|
||||
public fetchMediaType?: EventsOrRecordings;
|
||||
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public fetchEventsMediaType?: ClipsOrSnapshotsOrAll;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
@@ -68,7 +78,8 @@ export class FrigateCardSurround extends LitElement {
|
||||
if (
|
||||
!this.cameraManager ||
|
||||
!this.cardWideConfig ||
|
||||
!this.fetchMedia ||
|
||||
!this.fetchMediaType ||
|
||||
!this.fetchEventsMediaType ||
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
this.view.query ||
|
||||
@@ -78,18 +89,33 @@ export class FrigateCardSurround extends LitElement {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await changeViewToRecentEventsForCameraAndDependents(
|
||||
this,
|
||||
this.cameraManager,
|
||||
this.cardWideConfig,
|
||||
this.view,
|
||||
{
|
||||
allCameras: this.view.isGrid(),
|
||||
targetView: this.view.view,
|
||||
mediaType: this.fetchMedia,
|
||||
select: 'latest',
|
||||
},
|
||||
);
|
||||
|
||||
if (this.fetchMediaType === 'events') {
|
||||
await changeViewToRecentEventsForCameraAndDependents(
|
||||
this,
|
||||
this.cameraManager,
|
||||
this.cardWideConfig,
|
||||
this.view,
|
||||
{
|
||||
allCameras: this.view.isGrid(),
|
||||
targetView: this.view.view,
|
||||
eventsMediaType: this.fetchEventsMediaType,
|
||||
select: 'latest',
|
||||
},
|
||||
);
|
||||
} else if (this.fetchMediaType === 'recordings') {
|
||||
await changeViewToRecentRecordingForCameraAndDependents(
|
||||
this,
|
||||
this.cameraManager,
|
||||
this.cardWideConfig,
|
||||
this.view,
|
||||
{
|
||||
allCameras: this.view.isGrid(),
|
||||
targetView: this.view.view,
|
||||
select: 'latest',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1161,7 +1161,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
this._timelineSource = new TimelineDataSource(
|
||||
this.cameraManager,
|
||||
cameraIDs,
|
||||
this.timelineConfig.media,
|
||||
this.timelineConfig.events_media_type,
|
||||
this.timelineConfig.show_recordings,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -168,7 +168,7 @@ export class FrigateCardViewer extends LitElement {
|
||||
{
|
||||
allCameras: this.view.isGrid(),
|
||||
targetView: 'media',
|
||||
mediaType: mediaType,
|
||||
eventsMediaType: mediaType,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export class FrigateCardViews extends LitElement {
|
||||
public microphoneManager?: ReadonlyMicrophoneManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public triggeredCameraIDs?: Set<string>;
|
||||
public triggeredCameraIDs?: Set<string>;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('view') || changedProps.has('config')) {
|
||||
@@ -163,8 +163,11 @@ export class FrigateCardViews extends LitElement {
|
||||
class="${classMap(overallClasses)}"
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.fetchMedia=${this.view?.is('live')
|
||||
? this.overriddenConfig.live.controls.thumbnails.media
|
||||
.fetchMediaType=${this.view?.is('live')
|
||||
? this.overriddenConfig.live.controls.thumbnails.media_type
|
||||
: undefined}
|
||||
.fetchEventsMediaType=${this.view?.is('live')
|
||||
? this.overriddenConfig.live.controls.thumbnails.events_media_type
|
||||
: undefined}
|
||||
.thumbnailConfig=${!this.hide ? thumbnailConfig : undefined}
|
||||
.timelineConfig=${!this.hide ? miniTimelineConfig : undefined}
|
||||
|
||||
@@ -10,9 +10,13 @@ import {
|
||||
CONF_CAMERAS_GLOBAL_JSMPEG,
|
||||
CONF_CAMERAS_GLOBAL_WEBRTC_CARD,
|
||||
CONF_ELEMENTS,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_MEDIA_GALLERY,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_MENU_BUTTONS_CAMERA_UI,
|
||||
CONF_OVERRIDES,
|
||||
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_VIEW_INTERACTION_SECONDS,
|
||||
CONF_VIEW_SCAN_ACTIONS_UNTRIGGER,
|
||||
} from './const';
|
||||
@@ -516,4 +520,20 @@ const UPGRADES = [
|
||||
upgradeWithOverrides('media_viewer.auto_unmute', (data) =>
|
||||
data === 'all' ? ['selected', 'visible'] : data === 'never' ? null : arrayify(data),
|
||||
),
|
||||
upgradeMoveToWithOverrides(
|
||||
'live.controls.thumbnails.media',
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
|
||||
),
|
||||
upgradeMoveToWithOverrides(
|
||||
'timeline.media',
|
||||
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
),
|
||||
upgradeMoveToWithOverrides(
|
||||
'live.controls.timeline.media',
|
||||
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
),
|
||||
upgradeMoveToWithOverrides(
|
||||
'media_viewer.controls.timeline.media',
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
),
|
||||
];
|
||||
|
||||
+17
-8
@@ -626,26 +626,31 @@ const thumbnailsControlSchema = thumbnailsControlBaseSchema.extend({
|
||||
});
|
||||
export type ThumbnailsControlConfig = z.infer<typeof thumbnailsControlSchema>;
|
||||
|
||||
// *************************************************************************
|
||||
// Event Media Type Configuration
|
||||
// *************************************************************************
|
||||
const eventsMediaTypeSchema = z.enum(['all', 'clips', 'snapshots']);
|
||||
|
||||
// *************************************************************************
|
||||
// Timeline Configuration
|
||||
// *************************************************************************
|
||||
|
||||
const timelineCoreConfigDefault = {
|
||||
clustering_threshold: 3,
|
||||
media: 'all' as const,
|
||||
events_media_type: 'all' as const,
|
||||
window_seconds: 60 * 60,
|
||||
show_recordings: true,
|
||||
style: 'stack' as const,
|
||||
};
|
||||
|
||||
const timelineMediaSchema = z.enum(['all', 'clips', 'snapshots']);
|
||||
|
||||
const timelineCoreConfigSchema = z.object({
|
||||
clustering_threshold: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(timelineCoreConfigDefault.clustering_threshold),
|
||||
media: timelineMediaSchema.optional().default(timelineCoreConfigDefault.media),
|
||||
events_media_type: eventsMediaTypeSchema
|
||||
.optional()
|
||||
.default(timelineCoreConfigDefault.events_media_type),
|
||||
window_seconds: z
|
||||
.number()
|
||||
.min(1 * 60)
|
||||
@@ -867,7 +872,8 @@ export type FrigateCardPTZConfig = z.infer<typeof frigateCardPTZSchema>;
|
||||
|
||||
const liveThumbnailControlsDefaults = {
|
||||
...thumbnailControlsDefaults,
|
||||
media: 'all' as const,
|
||||
media_type: 'events' as const,
|
||||
events_media_type: 'all' as const,
|
||||
};
|
||||
|
||||
const liveConfigDefault = {
|
||||
@@ -899,9 +905,12 @@ const liveConfigDefault = {
|
||||
};
|
||||
|
||||
const livethumbnailsControlSchema = thumbnailsControlSchema.extend({
|
||||
media: z
|
||||
.enum(['all', 'clips', 'snapshots'])
|
||||
.default(liveConfigDefault.controls.thumbnails.media),
|
||||
media_type: z
|
||||
.enum(['events', 'recordings'])
|
||||
.default(liveConfigDefault.controls.thumbnails.media_type),
|
||||
events_media_type: eventsMediaTypeSchema.default(
|
||||
liveConfigDefault.controls.thumbnails.events_media_type,
|
||||
),
|
||||
});
|
||||
|
||||
const liveOverridableConfigSchema = z
|
||||
|
||||
+9
-7
@@ -146,8 +146,8 @@ export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE =
|
||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
|
||||
`${CONF_MEDIA_VIEWER}.controls.timeline.clustering_threshold` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA =
|
||||
`${CONF_MEDIA_VIEWER}.controls.timeline.media` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE =
|
||||
`${CONF_MEDIA_VIEWER}.controls.timeline.events_media_type` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE =
|
||||
`${CONF_MEDIA_VIEWER}.controls.timeline.mode` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS =
|
||||
@@ -189,8 +189,10 @@ export const CONF_LIVE_CONTROLS_PTZ_ORIENTATION =
|
||||
`${CONF_LIVE}.controls.ptz.orientation` as const;
|
||||
export const CONF_LIVE_CONTROLS_PTZ_POSITION =
|
||||
`${CONF_LIVE}.controls.ptz.position` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA =
|
||||
`${CONF_LIVE}.controls.thumbnails.media` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE =
|
||||
`${CONF_LIVE}.controls.thumbnails.media_type` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE =
|
||||
`${CONF_LIVE}.controls.thumbnails.events_media_type` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE =
|
||||
`${CONF_LIVE}.controls.thumbnails.mode` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE =
|
||||
@@ -205,8 +207,8 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
||||
`${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const;
|
||||
export const CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
|
||||
`${CONF_LIVE}.controls.timeline.clustering_threshold` as const;
|
||||
export const CONF_LIVE_CONTROLS_TIMELINE_MEDIA =
|
||||
`${CONF_LIVE}.controls.timeline.media` as const;
|
||||
export const CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE =
|
||||
`${CONF_LIVE}.controls.timeline.events_media_type` as const;
|
||||
export const CONF_LIVE_CONTROLS_TIMELINE_MODE =
|
||||
`${CONF_LIVE}.controls.timeline.mode` as const;
|
||||
export const CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS =
|
||||
@@ -256,7 +258,7 @@ const CONF_TIMELINE = 'timeline' as const;
|
||||
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
|
||||
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_EVENTS_MEDIA_TYPE = `${CONF_TIMELINE}.events_media_type` as const;
|
||||
export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
|
||||
export const CONF_TIMELINE_STYLE = `${CONF_TIMELINE}.style` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE =
|
||||
|
||||
+51
-27
@@ -86,7 +86,8 @@ import {
|
||||
CONF_LIVE_CONTROLS_PTZ_MODE,
|
||||
CONF_LIVE_CONTROLS_PTZ_ORIENTATION,
|
||||
CONF_LIVE_CONTROLS_PTZ_POSITION,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||
@@ -94,7 +95,7 @@ import {
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_MEDIA,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_MODE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_STYLE,
|
||||
@@ -138,7 +139,7 @@ import {
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
|
||||
@@ -174,7 +175,7 @@ import {
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
|
||||
CONF_TIMELINE_MEDIA,
|
||||
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_TIMELINE_SHOW_RECORDINGS,
|
||||
CONF_TIMELINE_STYLE,
|
||||
CONF_TIMELINE_WINDOW_SECONDS,
|
||||
@@ -452,15 +453,27 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
},
|
||||
];
|
||||
|
||||
protected _thumbnailMedias: EditorSelectOption[] = [
|
||||
protected _thumbnailMediaTypes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'events',
|
||||
label: localize('config.common.controls.thumbnails.media_types.events'),
|
||||
},
|
||||
{
|
||||
value: 'recordings',
|
||||
label: localize('config.common.controls.thumbnails.media_types.recordings'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _thumbnailEventsMediaTypes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'clips',
|
||||
label: localize('config.common.controls.thumbnails.medias.clips'),
|
||||
label: localize('config.common.controls.thumbnails.events_media_types.clips'),
|
||||
},
|
||||
{
|
||||
value: 'snapshots',
|
||||
label: localize('config.common.controls.thumbnails.medias.snapshots'),
|
||||
label: localize('config.common.controls.thumbnails.events_media_types.snapshots'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -498,11 +511,11 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
{ value: 'url', label: localize('config.image.modes.url') },
|
||||
];
|
||||
|
||||
protected _timelineMediaTypes: EditorSelectOption[] = [
|
||||
protected _timelineEventsMediaTypes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'all', label: localize('config.common.timeline.medias.all') },
|
||||
{ value: 'clips', label: localize('config.common.timeline.medias.clips') },
|
||||
{ value: 'snapshots', label: localize('config.common.timeline.medias.snapshots') },
|
||||
{ value: 'all', label: localize('config.common.timeline.events_media_types.all') },
|
||||
{ value: 'clips', label: localize('config.common.timeline.events_media_types.clips') },
|
||||
{ value: 'snapshots', label: localize('config.common.timeline.events_media_types.snapshots') },
|
||||
];
|
||||
|
||||
protected _timelineStyleTypes: EditorSelectOption[] = [
|
||||
@@ -1149,7 +1162,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
* @param configPathStyle Timeline style config path.
|
||||
* @param configPathWindowSeconds Timeline window config path.
|
||||
* @param configPathClusteringThreshold Clustering threshold config path.
|
||||
* @param configPathTimelineMedia Timeline media config path.
|
||||
* @param configPathTimelineEventsMediaType Timeline media config path.
|
||||
* @param configPathShowRecordings Show recordings config path.
|
||||
* @param defaultShowRecordings Default value of show_recordings.
|
||||
* @returns A rendered template.
|
||||
@@ -1158,7 +1171,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
configPathStyle: string,
|
||||
configPathWindowSeconds: string,
|
||||
configPathClusteringThreshold: string,
|
||||
configPathTimelineMedia: string,
|
||||
configPathTimelineEventsMediaType: string,
|
||||
configPathShowRecordings: string,
|
||||
defaultShowRecordings: boolean,
|
||||
): TemplateResult {
|
||||
@@ -1175,8 +1188,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
${this._renderNumberInput(configPathClusteringThreshold, {
|
||||
label: localize(`config.common.${CONF_TIMELINE_CLUSTERING_THRESHOLD}`),
|
||||
})}
|
||||
${this._renderOptionSelector(configPathTimelineMedia, this._timelineMediaTypes, {
|
||||
label: localize(`config.common.${CONF_TIMELINE_MEDIA}`),
|
||||
${this._renderOptionSelector(configPathTimelineEventsMediaType, this._timelineEventsMediaTypes, {
|
||||
label: localize(`config.common.${CONF_TIMELINE_EVENTS_MEDIA_TYPE}`),
|
||||
})}
|
||||
${this._renderSwitch(configPathShowRecordings, defaultShowRecordings, {
|
||||
label: localize(`config.common.${CONF_TIMELINE_SHOW_RECORDINGS}`),
|
||||
@@ -1188,7 +1201,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
* @param domain The submenu domain.
|
||||
* @param configPathWindowSeconds Timeline window config path.
|
||||
* @param configPathClusteringThreshold Clustering threshold config path.
|
||||
* @param configPathTimelineMedia Timeline media config path.
|
||||
* @param configPathTimelineEventsMediaType Timeline media config path.
|
||||
* @param configPathShowRecordings Show recordings config path.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
@@ -1198,7 +1211,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
configPathStyle: string,
|
||||
configPathWindowSeconds: string,
|
||||
configPathClusteringThreshold: string,
|
||||
configPathTimelineMedia: string,
|
||||
configPathTimelineEventsMediaType: string,
|
||||
configPathShowRecordings: string,
|
||||
showRecordingsDefault: boolean,
|
||||
): TemplateResult | void {
|
||||
@@ -1214,7 +1227,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
configPathStyle,
|
||||
configPathWindowSeconds,
|
||||
configPathClusteringThreshold,
|
||||
configPathTimelineMedia,
|
||||
configPathTimelineEventsMediaType,
|
||||
configPathShowRecordings,
|
||||
showRecordingsDefault,
|
||||
)}`,
|
||||
@@ -1330,7 +1343,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
show_download_control: boolean;
|
||||
},
|
||||
options?: {
|
||||
configPathMedia?: string;
|
||||
configPathMediaType?: string;
|
||||
configPathEventsMediaType?: string;
|
||||
configPathMode?: string;
|
||||
},
|
||||
): TemplateResult | void {
|
||||
@@ -1349,12 +1363,21 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
},
|
||||
)}`
|
||||
: html``}
|
||||
${options?.configPathMedia
|
||||
${options?.configPathMediaType
|
||||
? html`${this._renderOptionSelector(
|
||||
options.configPathMedia,
|
||||
this._thumbnailMedias,
|
||||
options.configPathMediaType,
|
||||
this._thumbnailMediaTypes,
|
||||
{
|
||||
label: localize('config.common.controls.thumbnails.media'),
|
||||
label: localize('config.common.controls.thumbnails.media_type'),
|
||||
},
|
||||
)}`
|
||||
: html``}
|
||||
${options?.configPathEventsMediaType
|
||||
? html`${this._renderOptionSelector(
|
||||
options.configPathEventsMediaType,
|
||||
this._thumbnailEventsMediaTypes,
|
||||
{
|
||||
label: localize('config.common.controls.thumbnails.events_media_type'),
|
||||
},
|
||||
)}`
|
||||
: html``}
|
||||
@@ -2101,7 +2124,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||
this._defaults.live.controls.thumbnails,
|
||||
{
|
||||
configPathMedia: CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA,
|
||||
configPathMediaType: CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE,
|
||||
configPathEventsMediaType: CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
|
||||
configPathMode: CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
|
||||
},
|
||||
)}
|
||||
@@ -2116,7 +2140,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_LIVE_CONTROLS_TIMELINE_STYLE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_MEDIA,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||
this._defaults.live.controls.timeline.show_recordings,
|
||||
)}
|
||||
@@ -2309,7 +2333,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||
this._defaults.media_viewer.controls.timeline.show_recordings,
|
||||
)}
|
||||
@@ -2347,7 +2371,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_TIMELINE_STYLE,
|
||||
CONF_TIMELINE_WINDOW_SECONDS,
|
||||
CONF_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_TIMELINE_MEDIA,
|
||||
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_TIMELINE_SHOW_RECORDINGS,
|
||||
this._defaults.timeline.show_recordings,
|
||||
)}
|
||||
|
||||
@@ -119,8 +119,13 @@
|
||||
},
|
||||
"thumbnails": {
|
||||
"editor_label": "Thumbnails",
|
||||
"media": "Whether to show thumbnails of clips or snapshots",
|
||||
"medias": {
|
||||
"media_type": "Whether to show thumnails of events or recordings",
|
||||
"media_types": {
|
||||
"events": "Events",
|
||||
"recordings": "Recordings"
|
||||
},
|
||||
"events_media_type": "Whether to show event thumbnails of clips or snapshots",
|
||||
"events_media_types": {
|
||||
"clips": "Clip thumbnails",
|
||||
"snapshots": "Snapshot thumbnails"
|
||||
},
|
||||
@@ -191,9 +196,9 @@
|
||||
},
|
||||
"timeline": {
|
||||
"clustering_threshold": "The count of events at which they are clustered (0=no clustering)",
|
||||
"media": "The media the timeline displays",
|
||||
"medias": {
|
||||
"all": "All media types",
|
||||
"events_media_type": "The events media the timeline displays",
|
||||
"events_media_types": {
|
||||
"all": "All event media types",
|
||||
"clips": "Clips",
|
||||
"snapshots": "Snapshots"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
||||
|
||||
export type ClipsOrSnapshots = 'clips' | 'snapshots';
|
||||
export type ClipsOrSnapshotsOrAll = 'clips' | 'snapshots' | 'all';
|
||||
export type EventsOrRecordings = 'events' | 'recordings';
|
||||
|
||||
export class FrigateCardError extends Error {
|
||||
context?: unknown;
|
||||
|
||||
@@ -24,7 +24,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
|
||||
view: View,
|
||||
options?: {
|
||||
allCameras?: boolean;
|
||||
mediaType?: ClipsOrSnapshotsOrAll;
|
||||
eventsMediaType?: ClipsOrSnapshotsOrAll;
|
||||
targetView?: FrigateCardView;
|
||||
select?: ResultSelectType;
|
||||
},
|
||||
@@ -37,7 +37,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
|
||||
}
|
||||
|
||||
const queries = createQueriesForEventsView(cameraManager, cardWideConfig, cameraIDs, {
|
||||
mediaType: options?.mediaType,
|
||||
eventsMediaType: options?.eventsMediaType,
|
||||
});
|
||||
if (!queries) {
|
||||
return;
|
||||
@@ -62,15 +62,15 @@ const createQueriesForEventsView = (
|
||||
cardWideConfig: CardWideConfig,
|
||||
cameraIDs: Set<string>,
|
||||
options?: {
|
||||
mediaType?: ClipsOrSnapshotsOrAll;
|
||||
eventsMediaType?: ClipsOrSnapshotsOrAll;
|
||||
},
|
||||
): EventMediaQueries | null => {
|
||||
const limit =
|
||||
cardWideConfig.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT;
|
||||
const eventQueries = cameraManager.generateDefaultEventQueries(cameraIDs, {
|
||||
limit: limit,
|
||||
...(options?.mediaType === 'clips' && { hasClip: true }),
|
||||
...(options?.mediaType === 'snapshots' && { hasSnapshot: true }),
|
||||
...(options?.eventsMediaType === 'clips' && { hasClip: true }),
|
||||
...(options?.eventsMediaType === 'snapshots' && { hasSnapshot: true }),
|
||||
});
|
||||
return eventQueries ? new EventMediaQueries(eventQueries) : null;
|
||||
};
|
||||
@@ -91,7 +91,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
view: View,
|
||||
options?: {
|
||||
allCameras?: boolean;
|
||||
targetView?: 'recording' | 'recordings';
|
||||
targetView?: FrigateCardView;
|
||||
select?: ResultSelectType;
|
||||
},
|
||||
): Promise<void> => {
|
||||
|
||||
@@ -1590,5 +1590,105 @@ describe('should handle version specific upgrades', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should rename thumbnails.media to thumbnails.events_media_type', () => {
|
||||
it.each([['all' as const], ['clips' as const], ['snapshots' as const]])(
|
||||
'%s',
|
||||
(mediaEventType: string) => {
|
||||
const config = {
|
||||
live: {
|
||||
controls: {
|
||||
thumbnails: {
|
||||
media: mediaEventType,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config).toEqual({
|
||||
live: {
|
||||
controls: {
|
||||
thumbnails: {
|
||||
events_media_type: mediaEventType,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('should rename timeline.media to timeline.events_media_type', () => {
|
||||
it.each([['all' as const], ['clips' as const], ['snapshots' as const]])(
|
||||
'%s',
|
||||
(mediaEventType: string) => {
|
||||
const config = {
|
||||
timeline: {
|
||||
media: mediaEventType,
|
||||
},
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config).toEqual({
|
||||
timeline: {
|
||||
events_media_type: mediaEventType,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('should rename live.controls.timeline.media to live.controls.timeline.events_media_type', () => {
|
||||
it.each([['all' as const], ['clips' as const], ['snapshots' as const]])(
|
||||
'%s',
|
||||
(mediaEventType: string) => {
|
||||
const config = {
|
||||
live: {
|
||||
controls: {
|
||||
timeline: {
|
||||
media: mediaEventType,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config).toEqual({
|
||||
live: {
|
||||
controls: {
|
||||
timeline: {
|
||||
events_media_type: mediaEventType,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('should rename media_viewer.controls.timeline.media to media_viewer.controls.timeline.events_media_type', () => {
|
||||
it.each([['all' as const], ['clips' as const], ['snapshots' as const]])(
|
||||
'%s',
|
||||
(mediaEventType: string) => {
|
||||
const config = {
|
||||
media_viewer: {
|
||||
controls: {
|
||||
timeline: {
|
||||
media: mediaEventType,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(upgradeConfig(config)).toBeTruthy();
|
||||
expect(config).toEqual({
|
||||
media_viewer: {
|
||||
controls: {
|
||||
timeline: {
|
||||
events_media_type: mediaEventType,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,7 +77,8 @@ describe('config defaults', () => {
|
||||
position: 'bottom-right',
|
||||
},
|
||||
thumbnails: {
|
||||
media: 'all',
|
||||
media_type: 'events',
|
||||
events_media_type: 'all',
|
||||
mode: 'right',
|
||||
show_details: true,
|
||||
show_download_control: true,
|
||||
@@ -87,7 +88,7 @@ describe('config defaults', () => {
|
||||
},
|
||||
timeline: {
|
||||
clustering_threshold: 3,
|
||||
media: 'all',
|
||||
events_media_type: 'all',
|
||||
mode: 'none',
|
||||
show_recordings: true,
|
||||
style: 'ribbon',
|
||||
@@ -143,7 +144,7 @@ describe('config defaults', () => {
|
||||
},
|
||||
timeline: {
|
||||
clustering_threshold: 3,
|
||||
media: 'all',
|
||||
events_media_type: 'all',
|
||||
mode: 'none',
|
||||
show_recordings: true,
|
||||
style: 'ribbon',
|
||||
@@ -268,7 +269,7 @@ describe('config defaults', () => {
|
||||
size: 100,
|
||||
},
|
||||
},
|
||||
media: 'all',
|
||||
events_media_type: 'all',
|
||||
show_recordings: true,
|
||||
style: 'stack',
|
||||
window_seconds: 3600,
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
|
||||
{},
|
||||
createView(),
|
||||
{
|
||||
mediaType: 'clips',
|
||||
eventsMediaType: 'clips',
|
||||
},
|
||||
);
|
||||
expect(elementHandler.viewHandler).not.toBeCalled();
|
||||
@@ -222,7 +222,7 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
|
||||
{},
|
||||
createView(),
|
||||
{
|
||||
mediaType: mediaType,
|
||||
eventsMediaType: mediaType,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+3
-3
@@ -10,10 +10,10 @@ export default defineConfig({
|
||||
// Thresholds will automatically be updated as coverage improves to avoid
|
||||
// back-sliding.
|
||||
thresholdAutoUpdate: true,
|
||||
statements: 72.72,
|
||||
branches: 61.97,
|
||||
statements: 72.73,
|
||||
branches: 61.93,
|
||||
functions: 74.01,
|
||||
lines: 72.62,
|
||||
lines: 72.63,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user