fix: Size the gallery fallback icon frame to fill the thumbnail box (#2705)

- Closes: #2701
This commit is contained in:
Dermot Duffy
2026-08-22 15:02:28 -07:00
committed by GitHub
parent 60e01cf537
commit b2f3243f3e
4 changed files with 94 additions and 17 deletions
@@ -68,10 +68,15 @@ export class AdvancedCameraCardThumbnailFeatureThumbnail extends LitElement {
}
protected render(): TemplateResult | void {
const imageOff = html`<advanced-camera-card-icon
.icon=${{ icon: 'mdi:image-off' }}
const renderIcon = (icon: string): TemplateResult =>
html`<div class="icon-container">
<advanced-camera-card-icon
.icon=${{ icon }}
title=${localize('thumbnail.no_thumbnail')}
></advanced-camera-card-icon> `;
></advanced-camera-card-icon>
</div>`;
const imageOff = renderIcon('mdi:image-off');
if (!this._embedThumbnailTask) {
return imageOff;
@@ -83,11 +88,7 @@ export class AdvancedCameraCardThumbnailFeatureThumbnail extends LitElement {
(embeddedThumbnail: string | null) =>
embeddedThumbnail ? html`<img src="${embeddedThumbnail}" />` : html``,
{
inProgressFunc: () =>
html`<advanced-camera-card-icon
.icon=${{ icon: 'mdi:image-refresh' }}
title=${localize('thumbnail.no_thumbnail')}
></advanced-camera-card-icon> `,
inProgressFunc: () => renderIcon('mdi:image-refresh'),
errorFunc: () => imageOff,
},
)
+15 -6
View File
@@ -37,16 +37,25 @@ img {
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;
width: 50%;
--mdc-icon-size: 100%;
color: var(--primary-text-color);
justify-content: center;
align-items: center;
border: 1px solid rgba(255, 255, 255, 0.3);
box-sizing: border-box;
border-radius: var(--advanced-camera-card-border-radius-final);
opacity: 0.2;
}
advanced-camera-card-icon {
width: 50%;
height: 50%;
--mdc-icon-size: 100%;
color: var(--primary-text-color);
}
+12
View File
@@ -226,6 +226,7 @@ const matchesEventQuery = (event: FrigateEvent, query: EventQuery): boolean =>
export class FakeFrigate {
private _events: FrigateEvent[] = [];
private _mediaURLs = new Map<string, string>();
private _thumbnailFailureStatus: number | null = null;
constructor(hass: FakeHASS) {
hass.registerCommand(
@@ -306,6 +307,13 @@ export class FakeFrigate {
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
// another. Frigate answers with JSON.
private _answerAsFrigate(
@@ -397,6 +405,10 @@ export class FakeFrigate {
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));
}
}
@@ -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 { PartialAdvancedCameraCardConfig } from '../../../src/config/types';
import { deepQuery } from '../../browser/dom';
import {
createFrigateCameraDescription,
createTestFrigateEvent,
EVENT_TIME_NEWER,
EVENT_TIME_OLDER,
FakeFrigate,
mountCardWithFrigate,
} from '../../browser/fake-frigate';
import type { MountedCard } from '../../browser/mounted-card';
import { MountedCardFactory, type MountedCard } from '../../browser/mounted-card';
import {
clickThumbnail,
createCameraHASS,
createStillImageCardConfig,
getBlockNotificationText,
getMediaViewerMediaURLs,
getThumbnails,
@@ -57,6 +61,57 @@ describe('AdvancedCameraCardGallery', () => {
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 () => {
const card = await mountCard([]);