diff --git a/src/components/thumbnail/feature/thumbnail.ts b/src/components/thumbnail/feature/thumbnail.ts
index b14af95c..e2d7d728 100644
--- a/src/components/thumbnail/feature/thumbnail.ts
+++ b/src/components/thumbnail/feature/thumbnail.ts
@@ -68,10 +68,15 @@ export class AdvancedCameraCardThumbnailFeatureThumbnail extends LitElement {
}
protected render(): TemplateResult | void {
- const imageOff = html` `;
+ const renderIcon = (icon: string): TemplateResult =>
+ html`
`;
+
+ 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`
` : html``,
{
- inProgressFunc: () =>
- html` `,
+ inProgressFunc: () => renderIcon('mdi:image-refresh'),
errorFunc: () => imageOff,
},
)
diff --git a/src/scss/thumbnail-feature-thumbnail.scss b/src/scss/thumbnail-feature-thumbnail.scss
index 6ea283a3..a76faf7a 100644
--- a/src/scss/thumbnail-feature-thumbnail.scss
+++ b/src/scss/thumbnail-feature-thumbnail.scss
@@ -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);
+}
diff --git a/tests/browser/fake-frigate.ts b/tests/browser/fake-frigate.ts
index 365642e4..4ec13e73 100644
--- a/tests/browser/fake-frigate.ts
+++ b/tests/browser/fake-frigate.ts
@@ -226,6 +226,7 @@ const matchesEventQuery = (event: FrigateEvent, query: EventQuery): boolean =>
export class FakeFrigate {
private _events: FrigateEvent[] = [];
private _mediaURLs = new Map();
+ 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));
}
}
diff --git a/tests/components/gallery/gallery.browser.test.ts b/tests/components/gallery/gallery.browser.test.ts
index c0c66d1c..01e57560 100644
--- a/tests/components/gallery/gallery.browser.test.ts
+++ b/tests/components/gallery/gallery.browser.test.ts
@@ -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(thumbnail, '.icon-container'),
+ 'the fallback icon frame',
+ );
+ const box = deepQuery(
+ thumbnail,
+ 'advanced-camera-card-thumbnail-feature-thumbnail',
+ );
+ assert(box);
+
+ const icon = deepQuery(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([]);