fix: Size the gallery fallback icon frame to fill the thumbnail box (#2705)
- Closes: #2701
This commit is contained in:
@@ -68,10 +68,15 @@ export class AdvancedCameraCardThumbnailFeatureThumbnail extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
const imageOff = html`<advanced-camera-card-icon
|
const renderIcon = (icon: string): TemplateResult =>
|
||||||
.icon=${{ icon: 'mdi:image-off' }}
|
html`<div class="icon-container">
|
||||||
title=${localize('thumbnail.no_thumbnail')}
|
<advanced-camera-card-icon
|
||||||
></advanced-camera-card-icon> `;
|
.icon=${{ icon }}
|
||||||
|
title=${localize('thumbnail.no_thumbnail')}
|
||||||
|
></advanced-camera-card-icon>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const imageOff = renderIcon('mdi:image-off');
|
||||||
|
|
||||||
if (!this._embedThumbnailTask) {
|
if (!this._embedThumbnailTask) {
|
||||||
return imageOff;
|
return imageOff;
|
||||||
@@ -83,11 +88,7 @@ export class AdvancedCameraCardThumbnailFeatureThumbnail extends LitElement {
|
|||||||
(embeddedThumbnail: string | null) =>
|
(embeddedThumbnail: string | null) =>
|
||||||
embeddedThumbnail ? html`<img src="${embeddedThumbnail}" />` : html``,
|
embeddedThumbnail ? html`<img src="${embeddedThumbnail}" />` : html``,
|
||||||
{
|
{
|
||||||
inProgressFunc: () =>
|
inProgressFunc: () => renderIcon('mdi:image-refresh'),
|
||||||
html`<advanced-camera-card-icon
|
|
||||||
.icon=${{ icon: 'mdi:image-refresh' }}
|
|
||||||
title=${localize('thumbnail.no_thumbnail')}
|
|
||||||
></advanced-camera-card-icon> `,
|
|
||||||
errorFunc: () => imageOff,
|
errorFunc: () => imageOff,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,16 +37,25 @@ img {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
advanced-camera-card-icon {
|
// The bordered frame occupies the same space an image would, with the icon
|
||||||
|
// centered at half size within it.
|
||||||
|
.icon-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 50%;
|
|
||||||
|
|
||||||
--mdc-icon-size: 100%;
|
|
||||||
color: var(--primary-text-color);
|
|
||||||
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
border-radius: var(--advanced-camera-card-border-radius-final);
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
advanced-camera-card-icon {
|
||||||
|
width: 50%;
|
||||||
|
height: 50%;
|
||||||
|
|
||||||
|
--mdc-icon-size: 100%;
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
}
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ const matchesEventQuery = (event: FrigateEvent, query: EventQuery): boolean =>
|
|||||||
export class FakeFrigate {
|
export class FakeFrigate {
|
||||||
private _events: FrigateEvent[] = [];
|
private _events: FrigateEvent[] = [];
|
||||||
private _mediaURLs = new Map<string, string>();
|
private _mediaURLs = new Map<string, string>();
|
||||||
|
private _thumbnailFailureStatus: number | null = null;
|
||||||
|
|
||||||
constructor(hass: FakeHASS) {
|
constructor(hass: FakeHASS) {
|
||||||
hass.registerCommand(
|
hass.registerCommand(
|
||||||
@@ -306,6 +307,13 @@ export class FakeFrigate {
|
|||||||
this._mediaURLs.set(this._getMediaKey(eventID, mediaType), url);
|
this._mediaURLs.set(this._getMediaKey(eventID, mediaType), url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse every thumbnail request with the given HTTP status.
|
||||||
|
*/
|
||||||
|
public failThumbnails(status = 404): void {
|
||||||
|
this._thumbnailFailureStatus = status;
|
||||||
|
}
|
||||||
|
|
||||||
// Answer a command addressed to this instance, refusing one meant for
|
// Answer a command addressed to this instance, refusing one meant for
|
||||||
// another. Frigate answers with JSON.
|
// another. Frigate answers with JSON.
|
||||||
private _answerAsFrigate(
|
private _answerAsFrigate(
|
||||||
@@ -397,6 +405,10 @@ export class FakeFrigate {
|
|||||||
throw new Error(`FakeFrigate has no thumbnail at: ${path}`);
|
throw new Error(`FakeFrigate has no thumbnail at: ${path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._thumbnailFailureStatus !== null) {
|
||||||
|
return new Response(null, { status: this._thumbnailFailureStatus });
|
||||||
|
}
|
||||||
|
|
||||||
return await fetch(createFixtureURL(SNAPSHOT_FIXTURE_FILENAME));
|
return await fetch(createFixtureURL(SNAPSHOT_FIXTURE_FILENAME));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { assert, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { FrigateEvent } from '../../../src/camera-manager/frigate/types';
|
import type { FrigateEvent } from '../../../src/camera-manager/frigate/types';
|
||||||
import type { PartialAdvancedCameraCardConfig } from '../../../src/config/types';
|
import type { PartialAdvancedCameraCardConfig } from '../../../src/config/types';
|
||||||
import { deepQuery } from '../../browser/dom';
|
import { deepQuery } from '../../browser/dom';
|
||||||
import {
|
import {
|
||||||
|
createFrigateCameraDescription,
|
||||||
createTestFrigateEvent,
|
createTestFrigateEvent,
|
||||||
EVENT_TIME_NEWER,
|
EVENT_TIME_NEWER,
|
||||||
EVENT_TIME_OLDER,
|
EVENT_TIME_OLDER,
|
||||||
|
FakeFrigate,
|
||||||
mountCardWithFrigate,
|
mountCardWithFrigate,
|
||||||
} from '../../browser/fake-frigate';
|
} from '../../browser/fake-frigate';
|
||||||
import type { MountedCard } from '../../browser/mounted-card';
|
import { MountedCardFactory, type MountedCard } from '../../browser/mounted-card';
|
||||||
import {
|
import {
|
||||||
clickThumbnail,
|
clickThumbnail,
|
||||||
|
createCameraHASS,
|
||||||
|
createStillImageCardConfig,
|
||||||
getBlockNotificationText,
|
getBlockNotificationText,
|
||||||
getMediaViewerMediaURLs,
|
getMediaViewerMediaURLs,
|
||||||
getThumbnails,
|
getThumbnails,
|
||||||
@@ -57,6 +61,57 @@ describe('AdvancedCameraCardGallery', () => {
|
|||||||
expect(image.src).toMatch(/^data:image\/png/);
|
expect(image.src).toMatch(/^data:image\/png/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should fill the thumbnail with a square frame when the picture cannot be fetched', async () => {
|
||||||
|
const hass = createCameraHASS([createFrigateCameraDescription()]);
|
||||||
|
const frigate = new FakeFrigate(hass);
|
||||||
|
frigate.setEvents([createTestFrigateEvent('newer', EVENT_TIME_NEWER)]);
|
||||||
|
frigate.failThumbnails();
|
||||||
|
|
||||||
|
const card = await MountedCardFactory.createFromSource(
|
||||||
|
createStillImageCardConfig({ view: { default: 'clips' } }),
|
||||||
|
hass,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForThumbnails(card, 1);
|
||||||
|
const thumbnail = getThumbnails(card.card)[0];
|
||||||
|
|
||||||
|
const frame = await card.waitForRender(
|
||||||
|
() => deepQuery<HTMLElement>(thumbnail, '.icon-container'),
|
||||||
|
'the fallback icon frame',
|
||||||
|
);
|
||||||
|
const box = deepQuery<HTMLElement>(
|
||||||
|
thumbnail,
|
||||||
|
'advanced-camera-card-thumbnail-feature-thumbnail',
|
||||||
|
);
|
||||||
|
assert(box);
|
||||||
|
|
||||||
|
const icon = deepQuery<HTMLElement>(frame, 'advanced-camera-card-icon');
|
||||||
|
assert(icon);
|
||||||
|
|
||||||
|
const boxRect = box.getBoundingClientRect();
|
||||||
|
const frameRect = frame.getBoundingClientRect();
|
||||||
|
const iconRect = icon.getBoundingClientRect();
|
||||||
|
|
||||||
|
const expectClose = (actual: number, expected: number): void =>
|
||||||
|
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(
|
||||||
|
Math.max(
|
||||||
|
4, // Allow at least 4 pixels of tolerance.
|
||||||
|
expected * 0.05,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The frame spans the whole thumbnail box and is square.
|
||||||
|
expectClose(frameRect.width, boxRect.width);
|
||||||
|
expectClose(frameRect.height, boxRect.height);
|
||||||
|
expectClose(frameRect.width, frameRect.height);
|
||||||
|
|
||||||
|
// The icon sits centered in the frame at half its size.
|
||||||
|
expectClose(iconRect.width, frameRect.width / 2);
|
||||||
|
expectClose(iconRect.height, frameRect.height / 2);
|
||||||
|
expectClose(iconRect.left - frameRect.left, (frameRect.width - iconRect.width) / 2);
|
||||||
|
expectClose(iconRect.top - frameRect.top, (frameRect.height - iconRect.height) / 2);
|
||||||
|
});
|
||||||
|
|
||||||
it('should say there is nothing to view when the camera has no events', async () => {
|
it('should say there is nothing to view when the camera has no events', async () => {
|
||||||
const card = await mountCard([]);
|
const card = await mountCard([]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user