Compare commits

...
Author SHA1 Message Date
Paul Bottein 77170b447f Fix favorites centering in the more info dialog (#55884) 2026-11-12 17:26:25 +02:00
c0ffeeca7 e2bb744202 MCP: change AI agent to AI app (#55885) 2026-11-12 17:25:49 +02:00
Wendelin 0eec83448a Revert camera ICE server filter and WebRTC-first memory (#55881)
* Revert camera ICE server filter and WebRTC-first memory

Follow-up to #55861 after review feedback:

- Use the full ICE server list from core again. The slow starts come from
  go2rtc, which stalls while it sets up TURN relays with the cloud ICE
  servers, so filtering in the browser does not help.
- Start HLS and WebRTC together again, remove the per-camera WebRTC-first
  memory and its 12 second HLS delay.
- Retry a WebRTC stream at most once, only after it played or on a backend
  error. A new connection that gets no answer or finds no path fails
  directly, so HLS or MJPEG takes over.

* Ignore stream URL errors of a removed HLS player

Switching cameras while the old HLS player was still fetching its stream
URL could mark HLS as failed for the new camera.
2026-11-12 14:51:53 +01:00
10 changed files with 45 additions and 441 deletions

No files matched your search

-54
View File
@@ -1,54 +0,0 @@
import type { IcePath } from "./limit-ice-servers";
// The fields of the transport, candidate pair and local candidate stats
interface IceStats {
id: string;
type: string;
selectedCandidatePairId?: string;
selected?: boolean;
localCandidateId?: string;
candidateType?: string;
relayProtocol?: string;
url?: string;
}
/**
* Reads the network path a connected peer connection uses. Returns
* `undefined` if the browser doesn't report it.
*/
export const getIcePath = async (
peerConnection: RTCPeerConnection
): Promise<IcePath | undefined> => {
const stats: Record<string, IceStats> = {};
try {
(await peerConnection.getStats()).forEach((report: IceStats) => {
stats[report.id] = report;
});
} catch (_err: unknown) {
return undefined;
}
const reports = Object.values(stats);
const pairId = reports.find(
(report) => report.type === "transport" && report.selectedCandidatePairId
)?.selectedCandidatePairId;
const pair = pairId
? stats[pairId]
: // Firefox has no transport stats
reports.find(
(report) => report.type === "candidate-pair" && report.selected
);
const candidate = pair?.localCandidateId
? stats[pair.localCandidateId]
: undefined;
if (!candidate?.candidateType) {
return undefined;
}
if (candidate.url?.startsWith("turn")) {
return { relayUrl: candidate.url };
}
if (candidate.candidateType === "relay" || candidate.relayProtocol) {
// Not all browsers report which TURN URL a relayed candidate came from
return undefined;
}
return {};
};
-103
View File
@@ -1,103 +0,0 @@
import { ensureArray } from "../array/ensure-array";
const isTurn = (url: string) => url.startsWith("turn");
const isTcpTurn = (url: string) =>
url.startsWith("turns:") || url.includes("transport=tcp");
const isTls443 = (url: string) =>
url.startsWith("turns:") && /:443(\?|$)/.test(url);
// "turn:host:3478?transport=udp" -> "host"
const getHost = (url: string) =>
url
.replace(/^[a-z]+:/i, "")
.replace(/\?.*$/, "")
.replace(/:\d+$/, "")
.toLowerCase();
/**
* Reduces the URLs of every ICE server to one STUN URL, one UDP TURN URL and
* one TCP TURN URL (preferring TLS on port 443, which passes most firewalls)
* per host.
*
* The browser gathers a candidate for every URL on every network interface and
* checks each of them against every remote candidate. Redundant URLs of the
* same host do not add a new network path, but multiply the number of checks
* and delay the connection by many seconds.
*/
export const limitIceServers = (
iceServers: RTCIceServer[] | undefined
): RTCIceServer[] | undefined =>
iceServers?.map((server) => {
const urls = ensureArray(server.urls);
if (urls.length < 2) {
return server;
}
const hosts = [...new Set(urls.map(getHost))];
return {
...server,
urls: hosts.flatMap((host) => {
const hostUrls = urls.filter((url) => getHost(url) === host);
const turnUrls = hostUrls.filter(isTurn);
const tcpTurnUrls = turnUrls.filter(isTcpTurn);
return [
hostUrls.find((url) => !isTurn(url)),
turnUrls.find((url) => !isTcpTurn(url)),
tcpTurnUrls.find(isTls443) ??
tcpTurnUrls.find((url) => url.startsWith("turns:")) ??
tcpTurnUrls[0],
].filter((url) => url !== undefined);
}),
};
});
// "turns:host" -> "turns:host:5349?transport=tcp", browsers report the URL of
// a candidate with the port and transport that were left out
const normalizeUrl = (url: string) => {
const scheme = url.slice(0, url.indexOf(":")).toLowerCase();
const port =
/:(\d+)(\?|$)/.exec(url)?.[1] ?? (scheme.endsWith("s") ? "5349" : "3478");
const transport =
/[?&]transport=(\w+)/i.exec(url)?.[1].toLowerCase() ??
(scheme === "turns" ? "tcp" : "udp");
return `${scheme}:${getHost(url)}:${port}?transport=${transport}`;
};
/** The network path a connection uses, without `relayUrl` it is direct. */
export interface IcePath {
relayUrl?: string;
}
/**
* Reduces the ICE servers to what a new connection needs to take the same
* path as a connection that is already running: only the TURN URL it relays
* over, or no TCP TURN URLs when it is direct. The UDP TURN URLs stay, as
* browsers also use them to find their public address.
*
* Returns `undefined` if the relay is not part of the ICE servers.
*/
export const limitIceServersToPath = (
iceServers: RTCIceServer[] | undefined,
path: IcePath
): RTCIceServer[] | undefined => {
const { relayUrl } = path;
if (relayUrl) {
const normalizedUrl = normalizeUrl(relayUrl);
for (const server of iceServers ?? []) {
const url = ensureArray(server.urls).find(
(serverUrl) => normalizeUrl(serverUrl) === normalizedUrl
);
if (url) {
return [{ ...server, urls: [url] }];
}
}
return undefined;
}
return limitIceServers(iceServers)
?.map((server) => ({
...server,
urls: ensureArray(server.urls).filter((url) => !isTcpTurn(url)),
}))
.filter((server) => server.urls.length);
};
+8 -98
View File
@@ -19,10 +19,8 @@ import {
type CameraEntity,
} from "../data/camera";
import {
hasWebRtcWorked,
MJPEG_STREAM,
selectCameraStreams,
setWebRtcWorked,
type CameraStream,
type CameraStreamTracks,
type CameraStreamType,
@@ -39,9 +37,6 @@ import type { HaWebRtcPlayer } from "./ha-web-rtc-player";
export type { CameraStreamType } from "../data/camera-streams";
// How long WebRTC gets on its own before HLS is started as well
const WEB_RTC_FIRST_TIMEOUT = 12000;
@customElement("ha-camera-stream")
export class HaCameraStream extends LitElement {
@state()
@@ -86,22 +81,12 @@ export class HaCameraStream extends LitElement {
@state() private _webRtcStreams?: CameraStreamTracks;
// WebRTC worked before for this camera, so HLS is not started with it
@state() private _webRtcFirst = false;
@query("ha-web-rtc-player") private _webRtcPlayer?: HaWebRtcPlayer;
private _visibleStreamType?: CameraStreamType;
private _webRtcFirstTimeout?: number;
private _capabilitiesRequest = 0;
private _streamsStartedAt = 0;
// If WebRTC loaded fast enough to be worth waiting for without HLS
private _webRtcLoadedInTime?: boolean;
private _thumbnailApi = memoizeOne(
(
api: ContextType<typeof apiContext>,
@@ -148,16 +133,15 @@ export class HaCameraStream extends LitElement {
public connectedCallback() {
super.connectedCallback();
this._connected = true;
if (this.hasUpdated && this._capabilities && this.stateObj) {
if (this.hasUpdated) {
// The players start again when reattached, so their results do as well
this._startStreams(this.stateObj.entity_id, this._capabilities);
this._resetStreams();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._connected = false;
this._stopWebRtcFirst();
}
protected updated(changedProps: PropertyValues<this>): void {
@@ -173,28 +157,6 @@ export class HaCameraStream extends LitElement {
this._visibleStreamType = visibleStreamType;
fireEvent(this, "stream-type-changed", { type: visibleStreamType });
}
this._rememberWebRtc(streams);
}
/**
* Remembers per camera if WebRTC is the stream that ends up playing in this
* browser, to start it without HLS the next time.
*/
private _rememberWebRtc(streams: CameraStream[]) {
if (
!this.stateObj ||
!this._webRtcStreams ||
(this._capabilities?.frontend_stream_types.length ?? 0) < 2 ||
streams.length !== 1
) {
// No choice between streams, or the choice is not final yet
return;
}
setWebRtcWorked(
this.stateObj.entity_id,
streams[0].type === STREAM_TYPE_WEB_RTC &&
this._webRtcLoadedInTime === true
);
}
protected render() {
@@ -271,8 +233,7 @@ export class HaCameraStream extends LitElement {
return this._streams(
this._capabilities?.frontend_stream_types,
this._hlsStreams,
this._webRtcStreams,
this._webRtcFirst
this._webRtcStreams
);
}
@@ -287,62 +248,19 @@ export class HaCameraStream extends LitElement {
};
return;
}
const entityId = this.stateObj!.entity_id;
const capabilities = await fetchCameraCapabilities(this._api, entityId);
const capabilities = await fetchCameraCapabilities(
this._api,
this.stateObj!.entity_id
);
if (request !== this._capabilitiesRequest) {
return;
}
if (this.isConnected) {
// Otherwise started when connected
this._startStreams(entityId, capabilities);
}
this._capabilities = capabilities;
}
private _resetStreams() {
this._hlsStreams = undefined;
this._webRtcStreams = undefined;
this._webRtcLoadedInTime = undefined;
this._stopWebRtcFirst();
}
private _startStreams(entityId: string, capabilities: CameraCapabilities) {
this._resetStreams();
this._streamsStartedAt = performance.now();
if (
capabilities.frontend_stream_types.length > 1 &&
hasWebRtcWorked(entityId)
) {
this._webRtcFirst = true;
this._webRtcFirstTimeout = window.setTimeout(
this._webRtcFirstTimedOut,
WEB_RTC_FIRST_TIMEOUT
);
}
}
private _webRtcFirstTimedOut = () => {
if (document.hidden) {
// A player started in the background is not stopped again, so wait
this._webRtcFirstTimeout = window.setTimeout(
this._webRtcFirstTimedOut,
WEB_RTC_FIRST_TIMEOUT
);
return;
}
this._webRtcFirstTimeout = undefined;
// Start HLS as well, WebRTC keeps connecting and takes over when it loads
this._webRtcFirst = false;
if (this.stateObj) {
// Don't wait for it the next time, also when it never reports a result
setWebRtcWorked(this.stateObj.entity_id, false);
}
};
private _stopWebRtcFirst() {
clearTimeout(this._webRtcFirstTimeout);
this._webRtcFirstTimeout = undefined;
this._webRtcFirst = false;
}
private async _getPosterUrl(): Promise<void> {
@@ -368,8 +286,6 @@ export class HaCameraStream extends LitElement {
}
private _handleWebRtcStreams(ev: HASSDomEvent<HASSDomEvents["streams"]>) {
clearTimeout(this._webRtcFirstTimeout);
this._webRtcFirstTimeout = undefined;
if (
!ev.detail.hasVideo &&
!ev.detail.hasAudio &&
@@ -380,10 +296,6 @@ export class HaCameraStream extends LitElement {
// Only a browser without WebRTC falls back to MJPEG.
return;
}
if (ev.detail.hasVideo && this._webRtcLoadedInTime === undefined) {
this._webRtcLoadedInTime =
performance.now() - this._streamsStartedAt <= WEB_RTC_FIRST_TIMEOUT;
}
this._webRtcStreams = ev.detail;
}
@@ -391,8 +303,7 @@ export class HaCameraStream extends LitElement {
(
supportedTypes?: CameraCapabilities["frontend_stream_types"],
hlsStreams?: CameraStreamTracks,
webRtcStreams?: CameraStreamTracks,
webRtcFirst?: boolean
webRtcStreams?: CameraStreamTracks
): CameraStream[] => {
if (__DEMO__) {
return [{ type: MJPEG_STREAM, visible: true }];
@@ -401,7 +312,6 @@ export class HaCameraStream extends LitElement {
supportedTypes,
hlsStreams,
webRtcStreams,
webRtcFirst,
});
}
);
+7 -2
View File
@@ -180,13 +180,14 @@ class HaHLSPlayer extends LitElement {
return;
}
if (!this.entityid) {
const entityId = this.entityid;
if (!entityId) {
return;
}
try {
const { url } = await fetchStreamUrl(
{ callWS: this._api.callWS, hassUrl: this._connection.hassUrl },
this.entityid
entityId
);
this._url = this._connection.hassUrl(url);
@@ -198,6 +199,10 @@ class HaHLSPlayer extends LitElement {
// eslint-disable-next-line
console.error(err);
if (!this.isConnected || entityId !== this.entityid) {
// Removed or switched camera while fetching, the result is outdated
return;
}
fireEvent(this, "streams", { hasAudio: false, hasVideo: false });
}
}
+24 -44
View File
@@ -9,12 +9,6 @@ import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { getIcePath } from "../common/webrtc/get-ice-path";
import type { IcePath } from "../common/webrtc/limit-ice-servers";
import {
limitIceServers,
limitIceServersToPath,
} from "../common/webrtc/limit-ice-servers";
import {
addWebRtcCandidate,
fetchWebRtcClientConfiguration,
@@ -37,8 +31,10 @@ const ANSWER_TIMEOUT = 20000;
// minutes to give up on their own, or never do.
const ICE_TIMEOUT = 30000;
// How often a stream that lost or never got its connection is started again
const MAX_RETRIES = 2;
// How often a stream that lost its connection or got a backend error is
// started again. A new connection that gets no answer or finds no path is not
// retried, as a retry would most likely end the same way.
const MAX_RETRIES = 1;
// Gives the backend time to tear down the failed session before the next one
const RETRY_DELAY = 1000;
@@ -192,16 +188,8 @@ export class HaWebRtcPlayer extends LitElement {
// Renegotiating the existing connection is not supported by all providers
// (go2rtc answers with a new DTLS fingerprint, which Firefox rejects), so
// start a new connection and session with the microphone track instead.
// The new connection only looks for the path the running stream uses
const icePath = this._peerConnection
? await getIcePath(this._peerConnection)
: undefined;
if (request !== this._microphoneRequest || !this.isConnected) {
track.stop();
return;
}
this._captureReconnectFrame();
await this._startWebRtc(track, icePath);
await this._startWebRtc(track);
}
private _captureReconnectFrame() {
@@ -238,8 +226,9 @@ export class HaWebRtcPlayer extends LitElement {
private _retries = 0;
// The stream only loaded with all ICE servers, so keep using them
private _allIceServers = false;
// The stream showed video since it was started, so a lost connection is
// worth a retry
private _hasPlayed = false;
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
@@ -256,8 +245,7 @@ export class HaWebRtcPlayer extends LitElement {
clearTimeout(this._hiddenCleanupTimeout);
this._hiddenCleanupTimeout = undefined;
} else {
this._retries = 0;
this._startWebRtc();
this._restartWebRtc();
}
};
@@ -298,8 +286,7 @@ export class HaWebRtcPlayer extends LitElement {
public override connectedCallback() {
super.connectedCallback();
if (this.hasUpdated && this.entityid) {
this._retries = 0;
this._startWebRtc();
this._restartWebRtc();
}
document.addEventListener("visibilitychange", this._handleVisibilityChange);
}
@@ -320,14 +307,18 @@ export class HaWebRtcPlayer extends LitElement {
if (!changedProperties.has("entityid")) {
return;
}
this._restartWebRtc();
}
/** Starts a new stream, not a retry of the current one. */
private _restartWebRtc() {
this._retries = 0;
this._allIceServers = false;
this._hasPlayed = false;
this._startWebRtc();
}
private async _startWebRtc(
localReturnAudioTrack?: MediaStreamTrack,
icePath?: IcePath
localReturnAudioTrack?: MediaStreamTrack
): Promise<void> {
this._cleanUp();
@@ -382,21 +373,9 @@ export class HaWebRtcPlayer extends LitElement {
this._logEvent("end clientConfig", this._clientConfig);
const { iceServers } = this._clientConfig.configuration;
const allIceServers = this._allIceServers || this._retries >= MAX_RETRIES;
this._peerConnection = new RTCPeerConnection({
...this._clientConfig.configuration,
// The last attempt uses all ICE servers, in case a removed URL is the
// only one that works in this network
// A known relay is taken from all of them. A direct path is not, as
// the URL it needs can be one that is removed.
iceServers:
(icePath &&
(icePath.relayUrl || !allIceServers) &&
limitIceServersToPath(iceServers, icePath)) ||
(allIceServers ? iceServers : limitIceServers(iceServers)),
});
this._peerConnection = new RTCPeerConnection(
this._clientConfig.configuration
);
if (this._clientConfig.dataChannel) {
// Some cameras (such as nest) require a data channel to establish a stream
@@ -529,6 +508,9 @@ export class HaWebRtcPlayer extends LitElement {
private _retryOrFail(error: WebRtcPlayerError) {
if (
this._retries >= MAX_RETRIES ||
// No answer or no path for a stream that never played, a retry would
// most likely end the same way
(error.type === "no_connection" && !this._hasPlayed) ||
// A microphone connect the user started is handled by starting again
// without the microphone. A retry keeps the microphone of the stream.
(this._localReturnAudioTrack &&
@@ -799,10 +781,8 @@ export class HaWebRtcPlayer extends LitElement {
this._logEvent("loadedData", data);
this._stopTimer();
if (this._retries >= MAX_RETRIES) {
this._allIceServers = true;
}
this._retries = 0;
this._hasPlayed = true;
// Without a track the microphone is still waiting for permission
if (this._microphoneConnecting && this._localReturnAudioTrack) {
+1 -36
View File
@@ -1,6 +1,3 @@
import { addRecent } from "../common/array/add-recent";
import { getStorage } from "../common/decorators/storage";
export const STREAM_TYPE_HLS = "hls";
export const STREAM_TYPE_WEB_RTC = "web_rtc";
@@ -25,8 +22,6 @@ export interface CameraStreamSelectionOptions {
supportedTypes?: StreamType[];
hlsStreams?: CameraStreamTracks;
webRtcStreams?: CameraStreamTracks;
/** Only start WebRTC and keep HLS off until WebRTC fails or takes too long. */
webRtcFirst?: boolean;
}
/**
@@ -37,7 +32,6 @@ export const selectCameraStreams = ({
supportedTypes,
hlsStreams,
webRtcStreams,
webRtcFirst,
}: CameraStreamSelectionOptions): CameraStream[] => {
if (!supportedTypes) {
return [];
@@ -76,7 +70,7 @@ export const selectCameraStreams = ({
}
// WebRTC is still loading
if (hlsFailed || (webRtcFirst && !hlsStreams)) {
if (hlsFailed) {
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
}
return [
@@ -84,32 +78,3 @@ export const selectCameraStreams = ({
{ type: STREAM_TYPE_WEB_RTC, visible: false },
];
};
const WEB_RTC_ENTITIES_KEY = "cameraWebRtcEntities";
const MAX_WEB_RTC_ENTITIES = 100;
const getWebRtcEntities = (): string[] => {
const entities = getStorage().getValue<string[]>(WEB_RTC_ENTITIES_KEY);
return Array.isArray(entities) ? entities : [];
};
/**
* If WebRTC was the stream that ended up playing for this camera in this
* browser. Stored per browser, as it depends on the network of the client.
*/
export const hasWebRtcWorked = (entityId: string): boolean =>
getWebRtcEntities().includes(entityId);
export const setWebRtcWorked = (entityId: string, worked: boolean): void => {
const entities = getWebRtcEntities();
if (entities.includes(entityId) === worked) {
return;
}
getStorage().setValue(
WEB_RTC_ENTITIES_KEY,
worked
? addRecent(entities, entityId, MAX_WEB_RTC_ENTITIES)
: entities.filter((entity) => entity !== entityId)
);
};
@@ -167,16 +167,12 @@ export class HaMoreInfoFavorites extends LitElement {
display: flex;
align-items: center;
justify-content: center;
margin: calc(var(--ha-space-2) * -1);
gap: var(--ha-space-4);
flex-wrap: wrap;
max-width: var(--favorite-items-max-width, 250px);
user-select: none;
}
.container > * {
margin: var(--ha-space-2);
}
.favorite {
display: block;
}
+3 -3
View File
@@ -3285,13 +3285,13 @@
},
"mcp": {
"header": "Model Context Protocol",
"description": "Add one of the URLs below to your AI agent. See the {documentation_link} for agent-specific instructions.",
"description_disabled": "See the {documentation_link} to learn how to connect your AI agent to Home Assistant.",
"description": "Add one of the URLs below to your AI app. See the {documentation_link} for app-specific instructions.",
"description_disabled": "See the {documentation_link} to learn how to connect your AI app to Home Assistant.",
"documentation": "documentation",
"enable": "[%key:ui::common::enable%]",
"disable": "[%key:ui::common::disable%]",
"disable_confirm_title": "Disable the Model Context Protocol server?",
"disable_confirm_text": "AI agents connected to the server will lose access to Home Assistant.",
"disable_confirm_text": "AI apps connected to the server will lose access to Home Assistant.",
"your_api_header": "Main URL",
"apis_header": "Individual URLs",
"copy_url": "Copy URL",
@@ -1,93 +0,0 @@
import { describe, expect, it } from "vitest";
import {
limitIceServers,
limitIceServersToPath,
} from "../../../src/common/webrtc/limit-ice-servers";
// Redundant URLs cost seconds of connection time, while dropping the wrong
// one (the UDP or the firewall friendly TLS relay) breaks remote access.
describe("limitIceServers", () => {
it("works", () => {
expect(limitIceServers(undefined)).toBeUndefined();
expect(
limitIceServers([
{ urls: "stun:stun.example.com:3478" },
{ urls: ["stun:a.example.com:3478", "stun:a.example.com:80"] },
{
urls: [
"turn:t.example.com:3478?transport=udp",
"turn:t.example.com:3478?transport=tcp",
"turns:t.example.com:5349?transport=tcp",
"turn:t.example.com:443?transport=udp",
"turn:t.example.com:80?transport=tcp",
"turns:t.example.com:443?transport=tcp",
],
username: "user",
credential: "secret",
},
{
urls: [
"turn:u.example.com:80?transport=tcp",
"turn:u.example.com:443?transport=tcp",
],
},
// Different hosts are different network paths, all are kept
{
urls: [
"stun:v.example.com",
"turn:v.example.com:3478",
"turn:v.example.com:3479",
"turn:w.example.com:3478",
],
},
])
).toEqual([
{ urls: "stun:stun.example.com:3478" },
{ urls: ["stun:a.example.com:3478"] },
{
urls: [
"turn:t.example.com:3478?transport=udp",
"turns:t.example.com:443?transport=tcp",
],
username: "user",
credential: "secret",
},
{ urls: ["turn:u.example.com:80?transport=tcp"] },
{
urls: [
"stun:v.example.com",
"turn:v.example.com:3478",
"turn:w.example.com:3478",
],
},
]);
const iceServers = [
{ urls: ["stun:a.example.com:3478", "stun:a.example.com:80"] },
{
urls: ["turn:t.example.com", "turns:t.example.com:443"],
username: "user",
},
];
// A direct path keeps the UDP relay, which is also used to find the
// public address. A relayed one only needs its own relay.
expect(limitIceServersToPath(iceServers, {})).toEqual([
{ urls: ["stun:a.example.com:3478"] },
{ urls: ["turn:t.example.com"], username: "user" },
]);
// Browsers report the URL with the port and transport that were left out
expect(
limitIceServersToPath(iceServers, {
relayUrl: "turns:t.example.com:443?transport=tcp",
})
).toEqual([{ urls: ["turns:t.example.com:443"], username: "user" }]);
expect(
limitIceServersToPath(iceServers, {
relayUrl: "turn:t.example.com:3478?transport=udp",
})
).toEqual([{ urls: ["turn:t.example.com"], username: "user" }]);
expect(
limitIceServersToPath(iceServers, { relayUrl: "turn:other.example.com" })
).toBeUndefined();
});
});
+1 -3
View File
@@ -27,11 +27,9 @@ describe("selectCameraStreams", () => {
selectCameraStreams({ supportedTypes: ["hls"], hlsStreams: failed })
).toEqual([{ type: "mjpeg", visible: true }]);
// Loading: HLS is shown until WebRTC is ready, unless WebRTC goes first
// Loading: HLS is shown until WebRTC is ready
expect(select({})).toBe("hls (web_rtc)");
expect(select({ hlsStreams: ok })).toBe("hls (web_rtc)");
expect(select({ webRtcFirst: true })).toBe("web_rtc");
expect(select({ webRtcFirst: true, hlsStreams: ok })).toBe("hls (web_rtc)");
expect(select({ hlsStreams: failed })).toBe("web_rtc");
// WebRTC loaded: always preferred, also when only HLS has audio