fix: Stop the live media frame collapsing while a stream loads (#2671)

- Closes: #2574
This commit is contained in:
Dermot Duffy
2026-08-09 21:17:06 -07:00
committed by GitHub
parent 45f88a86a4
commit 98a0084e7a
8 changed files with 277 additions and 47 deletions
@@ -1,3 +1,4 @@
import type { LitElement } from 'lit';
import { describe, expect, it } from 'vitest';
import type { MediaLoadedInfoEventDetail } from '../../src/types';
@@ -19,10 +20,6 @@ const IMAGE_ERROR_EVENT = 'advanced-camera-card:image-updating-player:error';
const FIXTURE_WIDTH = 320;
const FIXTURE_HEIGHT = 180;
interface RenderedElement extends Element {
updateComplete: Promise<boolean>;
}
const mount = async (): Promise<MountedCard> => {
const hass = createGenericCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } });
return await MountedCardFactory.createFromSource(createStillImageCardConfig(), hass);
@@ -38,7 +35,7 @@ describe('AdvancedCameraCardImageUpdatingPlayer', () => {
it('should announce the media load once even when the card re-renders', async () => {
const card = await mount();
const player = await card.waitForSelector<RenderedElement>(
const player = await card.waitForSelector<LitElement>(
'advanced-camera-card-image-updating-player',
);
await card.events.waitForFirst('advanced-camera-card:media:loaded');
@@ -0,0 +1,181 @@
import type { LitElement } from 'lit';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../src/components-lib/live/liveness/detectors/entity-availability';
import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
import { aspectRatioToString } from '../../../src/utils/basic';
import { deepQuery } from '../../browser/dom';
import { createFixtureURL, SNAPSHOT_FIXTURE_FILENAME } from '../../browser/fixtures';
import { MountedCardFactory, type MountedCard } from '../../browser/mounted-card';
import { createUnansweredMediaURL, useTestMedia } from '../../browser/test-media';
import {
CAMERA_ENTITY,
createGenericCameraHASS,
createStillImageCardConfig,
} from '../../browser/test-utils';
useTestMedia();
const IMAGE_URL = createFixtureURL(SNAPSHOT_FIXTURE_FILENAME);
const DEFAULT_RATIO = [16, 9];
const DEFAULT_RATIO_STYLE = aspectRatioToString({ ratio: DEFAULT_RATIO });
const PORTRAIT_RATIO = [9, 16];
const PORTRAIT_RATIO_STYLE = aspectRatioToString({ ratio: PORTRAIT_RATIO });
const mount = async (
camera: RawAdvancedCameraCardConfig,
live?: RawAdvancedCameraCardConfig,
): Promise<MountedCard> =>
await MountedCardFactory.createFromSource(
createStillImageCardConfig({
cameras: [{ camera_entity: CAMERA_ENTITY, ...camera }],
live: { show_image_during_load: false, ...live },
}),
createGenericCameraHASS(),
);
// The provider writes `sized` during its own render, so its render is what a
// test has to wait for rather than that of the card that drew it.
const getProvider = async (card: MountedCard): Promise<LitElement> => {
const provider = await card.waitForSelector<LitElement>(
'advanced-camera-card-live-provider',
);
await provider.updateComplete;
return provider;
};
const getReservedAspectRatio = (provider: Element): string =>
getComputedStyle(provider).aspectRatio;
const getMeasuredAspectRatio = (element: Element): number => {
const { width, height } = element.getBoundingClientRect();
return width / height;
};
// A browser lays a box out in fractional pixels, so a measurement never divides
// back to exactly the ratio it was given.
const expectMeasuredAspectRatio = (element: Element, ratio: number[]): void =>
expect(getMeasuredAspectRatio(element)).toBeCloseTo(ratio[0] / ratio[1], 2);
const waitForMeasuredAspectRatio = async (
card: MountedCard,
ratio: number[],
): Promise<void> => {
await card.waitForRender(
() => {
try {
expectMeasuredAspectRatio(card.card, ratio);
return true;
} catch {
return null;
}
},
`the card being measured as ${aspectRatioToString({ ratio })}`,
);
};
describe('AdvancedCameraCardLiveProvider', () => {
afterEach(() => {
vi.useRealTimers();
});
describe('should reserve an aspect ratio until the frame has a size', () => {
it('should set default absent other information', async () => {
const card = await mount({
live_provider: 'image',
image: { mode: 'url', url: createUnansweredMediaURL(), refresh_seconds: 0 },
});
const provider = await getProvider(card);
expect(provider.hasAttribute('sized')).toBe(false);
expect(getReservedAspectRatio(provider)).toBe(DEFAULT_RATIO_STYLE);
// The reservation is what keeps the card off its 100px minimum height.
expectMeasuredAspectRatio(card.card, DEFAULT_RATIO);
});
it('should reserve an aspect ratio when the placeholder snapshot is allowed but has not loaded', async () => {
const card = await mount(
{
live_provider: 'image',
image: { mode: 'url', url: createUnansweredMediaURL(), refresh_seconds: 0 },
},
{ show_image_during_load: true },
);
const provider = await getProvider(card);
// The image loading snapshot is on screen and is what would size the
// frame, so its absence here is the image never being decoded rather than
// never being asked for.
expect(deepQuery(provider, 'advanced-camera-card-live-image')).not.toBeNull();
expect(provider.hasAttribute('sized')).toBe(false);
expect(getReservedAspectRatio(provider)).toBe(DEFAULT_RATIO_STYLE);
expectMeasuredAspectRatio(card.card, DEFAULT_RATIO);
});
it('should size to the snapshot once it has loaded', async () => {
const card = await mount(
{
live_provider: 'image',
image: { mode: 'url', url: IMAGE_URL, refresh_seconds: 0 },
},
{ show_image_during_load: true },
);
const provider = await getProvider(card);
await card.waitForRender(
() => provider.hasAttribute('sized') || null,
'the frame being sized by the loaded snapshot',
);
expect(getReservedAspectRatio(provider)).toBe('auto');
});
it('should size to a configured camera aspect ratio', async () => {
const card = await mount({
live_provider: 'image',
image: { mode: 'url', url: createUnansweredMediaURL(), refresh_seconds: 0 },
dimensions: { aspect_ratio: PORTRAIT_RATIO_STYLE },
});
const provider = await getProvider(card);
// The camera declares the shape of its media, so the media itself is
// sized and must not be letterboxed into the reserved ratio.
expect(provider.hasAttribute('sized')).toBe(true);
expect(getReservedAspectRatio(provider)).toBe('auto');
await waitForMeasuredAspectRatio(card, PORTRAIT_RATIO);
});
it('should reserve an aspect ratio again once a camera goes unavailable', async () => {
const card = await mount({
live_provider: 'image',
image: { mode: 'url', url: IMAGE_URL, refresh_seconds: 0 },
dimensions: { aspect_ratio: PORTRAIT_RATIO_STYLE },
});
const provider = await getProvider(card);
await waitForMeasuredAspectRatio(card, PORTRAIT_RATIO);
expect(provider.hasAttribute('sized')).toBe(true);
vi.useFakeTimers();
// This triggers a notification to be rendered in the default aspect
// ratio.
card.setEntityState(CAMERA_ENTITY, 'unavailable');
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS);
await card.waitForRender(
() => !provider.hasAttribute('sized') || null,
'the frame giving up its size',
);
expect(getReservedAspectRatio(provider)).toBe(DEFAULT_RATIO_STYLE);
expectMeasuredAspectRatio(card.card, DEFAULT_RATIO);
});
});
});
+6
View File
@@ -440,6 +440,12 @@ describe('aspectRatioToStyle', () => {
it('invalid ratio', () => {
expect(aspectRatioToStyle({ ratio: [4] })).toEqual({ 'aspect-ratio': 'auto' });
});
it.each([[[0, 0]], [[-4, 3]], [[Infinity, 3]]])(
'should ignore a ratio with a side that describes no shape: %s',
(ratio: number[]) => {
expect(aspectRatioToStyle({ ratio })).toEqual({ 'aspect-ratio': 'auto' });
},
);
});
describe('desparsifyArrays', () => {