From b2f3243f3e29b999a773d1820dc7f4b0a83b9dd9 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 22 Aug 2026 15:02:28 -0700 Subject: [PATCH] fix: Size the gallery fallback icon frame to fill the thumbnail box (#2705) - Closes: #2701 --- src/components/thumbnail/feature/thumbnail.ts | 19 +++--- src/scss/thumbnail-feature-thumbnail.scss | 21 +++++-- tests/browser/fake-frigate.ts | 12 ++++ .../gallery/gallery.browser.test.ts | 59 ++++++++++++++++++- 4 files changed, 94 insertions(+), 17 deletions(-) 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([]);