chore: Enforce a few house rules via eslint (#2539)
This commit is contained in:
committed by
dermotduffy
parent
5572ec728e
commit
921d45e577
@@ -1,18 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
parser: '@typescript-eslint/parser', // Specifies the ESLint parser
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
|
|
||||||
sourceType: 'module', // Allows for the use of imports
|
|
||||||
},
|
|
||||||
extends: [
|
|
||||||
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
|
|
||||||
'prettier', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier
|
|
||||||
],
|
|
||||||
rules: {
|
|
||||||
// Place to specify ESLint rules. Can be used to overwrite rules specified
|
|
||||||
// from the extended configs e.g.
|
|
||||||
// "@typescript-eslint/explicit-function-return-type": "off",
|
|
||||||
'@typescript-eslint/no-explicit-any': 'error',
|
|
||||||
'no-multi-assign': 'error',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
+3
-3
@@ -9,7 +9,7 @@ Commands: `yarn install`, `yarn run build`, `yarn run test`, `yarn run coverage`
|
|||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
- Conventional Commits (used by semantic-release).
|
- Conventional Commits (used by semantic-release).
|
||||||
- Comments explain **why**, not what — only when intent isn't obvious from code.
|
- Comments explain **why**, not what -- only when intent isn't obvious from code.
|
||||||
|
|
||||||
## TypeScript
|
## TypeScript
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ Commands: `yarn install`, `yarn run build`, `yarn run test`, `yarn run coverage`
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- Consolidate duplicate logic when adding helpers; don't leave redundant inline expressions.
|
- Consolidate duplicate logic when adding helpers; don't leave redundant inline expressions.
|
||||||
- **Naming consistency:** Renames span all layers — schema, types, CSS, localization keys, templates, docs. All schema fields must appear in the documentation table.
|
- **Naming consistency:** Renames span all layers -- schema, types, CSS, localization keys, templates, docs. All schema fields must appear in the documentation table.
|
||||||
- **Separation of concerns:** `components/` renders only; logic in `components-lib/` controllers (using `ReactiveController` where needed).
|
- **Separation of concerns:** `components/` renders only; logic in `components-lib/` controllers (using `ReactiveController` where needed).
|
||||||
- **Manager pattern:** `CardController` orchestrates managers (`ConfigManager`, `HASSManager`, `ViewManager`, etc.). New cross-cutting concerns → new manager.
|
- **Manager pattern:** `CardController` orchestrates managers (`ConfigManager`, `HASSManager`, `ViewManager`, etc.). New cross-cutting concerns → new manager.
|
||||||
- **Module conventions:** `types.ts` for types/schemas, `*-manager.ts` for coordinators, `*-controller.ts` for logic, `utils/` for helpers.
|
- **Module conventions:** `types.ts` for types/schemas, `*-manager.ts` for coordinators, `*-controller.ts` for logic, `utils/` for helpers.
|
||||||
@@ -40,7 +40,7 @@ Commands: `yarn install`, `yarn run build`, `yarn run test`, `yarn run coverage`
|
|||||||
## Preferences
|
## Preferences
|
||||||
|
|
||||||
- Think through UX states and edge cases **before** writing code.
|
- Think through UX states and edge cases **before** writing code.
|
||||||
- No re-exports or pass-through files — import from source directly.
|
- No re-exports or pass-through files -- import from source directly.
|
||||||
- Derive TypeScript types from Zod schemas (`z.infer<typeof schema>`); no parallel interfaces.
|
- Derive TypeScript types from Zod schemas (`z.infer<typeof schema>`); no parallel interfaces.
|
||||||
- User-facing schemas: only user-configurable fields. Internal fields go in a derived interface (`interface InternalFoo extends Foo { ... }`).
|
- User-facing schemas: only user-configurable fields. Internal fields go in a derived interface (`interface InternalFoo extends Foo { ... }`).
|
||||||
- Shared schemas go in `config/schema/common/` from the start.
|
- Shared schemas go in `config/schema/common/` from the start.
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Turns the card into a phone-style ringer that answers a [two-way audio
|
|||||||
call](../usage/2-way-audio.md) when somebody presses a doorbell. Intended for a
|
call](../usage/2-way-audio.md) when somebody presses a doorbell. Intended for a
|
||||||
wall-mounted tablet sitting on a dashboard with a doorbell camera.
|
wall-mounted tablet sitting on a dashboard with a doorbell camera.
|
||||||
|
|
||||||
Minimal configuration — just point it at a doorbell-capable camera:
|
Minimal configuration -- just point it at a doorbell-capable camera:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:advanced-camera-card
|
type: custom:advanced-camera-card
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ status_bar:
|
|||||||
|
|
||||||
### Available Items
|
### Available Items
|
||||||
|
|
||||||
| Button name | Description |
|
| Button name | Description |
|
||||||
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `engine` | The icon of the camera engine for the relevant camera. |
|
| `engine` | The icon of the camera engine for the relevant camera. |
|
||||||
| `issues` | Indicator icons that appear while any card issue is active — e.g. configuration error, configuration upgrade available, Home Assistant connection lost, camera initialization failed, legacy `frigate-hass-card` resource detected, media (live/recorded/image) not loading, media query failed, or view cannot be resolved. See the warning below about disabling this item. |
|
| `issues` | Indicator icons that appear while any card issue is active -- e.g. configuration error, configuration upgrade available, Home Assistant connection lost, camera initialization failed, legacy `frigate-hass-card` resource detected, media (live/recorded/image) not loading, media query failed, or view cannot be resolved. See the warning below about disabling this item. |
|
||||||
| `resolution` | The detected media resolution (if any). |
|
| `resolution` | The detected media resolution (if any). |
|
||||||
| `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). |
|
| `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). |
|
||||||
| `technology` | The detected media technology (if any). |
|
| `technology` | The detected media technology (if any). |
|
||||||
| `title` | The media title. |
|
| `title` | The media title. |
|
||||||
|
|
||||||
### Options for each item
|
### Options for each item
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -91,7 +91,7 @@ automations:
|
|||||||
### Fullscreen with display mode and substream switching
|
### Fullscreen with display mode and substream switching
|
||||||
|
|
||||||
This example adds a custom menu button that switches to single display mode,
|
This example adds a custom menu button that switches to single display mode,
|
||||||
activates the HD substream, and enters card fullscreen — all in a single tap.
|
activates the HD substream, and enters card fullscreen -- all in a single tap.
|
||||||
An automation restores the grid layout and substream when fullscreen is exited.
|
An automation restores the grid layout and substream when fullscreen is exited.
|
||||||
This is useful in [grid](configuration/live.md) layouts where the card's
|
This is useful in [grid](configuration/live.md) layouts where the card's
|
||||||
standard fullscreen button would show all cameras rather than a single HD
|
standard fullscreen button would show all cameras rather than a single HD
|
||||||
@@ -376,9 +376,9 @@ elements:
|
|||||||
|
|
||||||
The [`doorbell` profile](configuration/profiles.md?id=doorbell) turns a dashboard into a phone-like ringer when somebody presses the doorbell, by setting [`view.triggers.actions.trigger: call`](configuration/view.md?id=triggers) and auto-discovering [HA `event.*` entities](https://www.home-assistant.io/integrations/event/#device-class) with `device_class: doorbell` on the camera's device (Ring, UniFi Protect, Nest, DoorBird, Reolink, etc.). The intended deployment is a wall-mounted tablet sitting on the dashboard.
|
The [`doorbell` profile](configuration/profiles.md?id=doorbell) turns a dashboard into a phone-like ringer when somebody presses the doorbell, by setting [`view.triggers.actions.trigger: call`](configuration/view.md?id=triggers) and auto-discovering [HA `event.*` entities](https://www.home-assistant.io/integrations/event/#device-class) with `device_class: doorbell` on the camera's device (Ring, UniFi Protect, Nest, DoorBird, Reolink, etc.). The intended deployment is a wall-mounted tablet sitting on the dashboard.
|
||||||
|
|
||||||
A doorbell press is instantaneous, so the card synthesises a ring window from [`view.triggers.event_hold_seconds`](configuration/view.md?id=triggers) (default `30`s) — long enough for a typical phone-style answer window. `untrigger_delay_seconds` then lingers past that, same as for any stateful trigger.
|
A doorbell press is instantaneous, so the card synthesises a ring window from [`view.triggers.event_hold_seconds`](configuration/view.md?id=triggers) (default `30`s) -- long enough for a typical phone-style answer window. `untrigger_delay_seconds` then lingers past that, same as for any stateful trigger.
|
||||||
|
|
||||||
`triggers.motion`, `triggers.occupancy`, and `triggers.media_events` are off by default — only the explicit doorbell press triggers the call, so casual motion won't make the card ring.
|
`triggers.motion`, `triggers.occupancy`, and `triggers.media_events` are off by default -- only the explicit doorbell press triggers the call, so casual motion won't make the card ring.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:advanced-camera-card
|
type: custom:advanced-camera-card
|
||||||
@@ -398,9 +398,9 @@ profiles:
|
|||||||
|
|
||||||
#### With a Zigbee (ZHA / deCONZ) doorbell button
|
#### With a Zigbee (ZHA / deCONZ) doorbell button
|
||||||
|
|
||||||
Zigbee buttons connected via ZHA or deCONZ typically don't expose a per-device entity — they fire raw HA bus events (`zha_event`, `deconz_event`) shared across every Zigbee device on the integration. The `doorbell` profile's auto-discovery doesn't cover this case; reuse the profile (it still wires up `trigger: call` / `untrigger: call` and the ring window) but opt out of auto-discovery per camera and add [`triggers.events`](configuration/cameras/README.md?id=events) with an `event_data` filter to pick out the right device.
|
Zigbee buttons connected via ZHA or deCONZ typically don't expose a per-device entity -- they fire raw HA bus events (`zha_event`, `deconz_event`) shared across every Zigbee device on the integration. The `doorbell` profile's auto-discovery doesn't cover this case; reuse the profile (it still wires up `trigger: call` / `untrigger: call` and the ring window) but opt out of auto-discovery per camera and add [`triggers.events`](configuration/cameras/README.md?id=events) with an `event_data` filter to pick out the right device.
|
||||||
|
|
||||||
You can copy the exact `device_ieee` (ZHA) or `id` (deCONZ) and command values straight out of **Developer tools → Events** in Home Assistant — listen to the event type, press the doorbell, and use whatever appears in the `data` payload.
|
You can copy the exact `device_ieee` (ZHA) or `id` (deCONZ) and command values straight out of **Developer tools → Events** in Home Assistant -- listen to the event type, press the doorbell, and use whatever appears in the `data` payload.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:advanced-camera-card
|
type: custom:advanced-camera-card
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ between clicking the download button and the download starting.
|
|||||||
- If you are using a custom `client_id` setting in your `frigate.yml` file (the
|
- If you are using a custom `client_id` setting in your `frigate.yml` file (the
|
||||||
configuration file for the Frigate backend itself), the card will auto-detect
|
configuration file for the Frigate backend itself), the card will auto-detect
|
||||||
it from the camera entity. If auto-detection fails (e.g. no `camera_entity`
|
it from the camera entity. If auto-detection fails (e.g. no `camera_entity`
|
||||||
is configured), set it manually — see [Frigate engine
|
is configured), set it manually -- see [Frigate engine
|
||||||
configuration](configuration/cameras/engine.md?id=frigate).
|
configuration](configuration/cameras/engine.md?id=frigate).
|
||||||
- You must have the `Enable the media browser` option enabled for the Frigate
|
- You must have the `Enable the media browser` option enabled for the Frigate
|
||||||
integration, in order for media fetches to work for the card. Media fetches
|
integration, in order for media fetches to work for the card. Media fetches
|
||||||
@@ -305,7 +305,7 @@ Picture-in-Picture (PIP) uses the browser's [native video PIP
|
|||||||
API](https://caniuse.com/picture-in-picture) which floats the raw video element
|
API](https://caniuse.com/picture-in-picture) which floats the raw video element
|
||||||
into a small window. This means:
|
into a small window. This means:
|
||||||
|
|
||||||
- **No card UI in the PIP window.** Only the video itself is shown — no menu,
|
- **No card UI in the PIP window.** Only the video itself is shown -- no menu,
|
||||||
status bar, timeline, or other card elements. The card remains fully
|
status bar, timeline, or other card elements. The card remains fully
|
||||||
functional on the dashboard behind it.
|
functional on the dashboard behind it.
|
||||||
- **Limited browser support.** Not all browsers support the PIP API. See
|
- **Limited browser support.** Not all browsers support the PIP API. See
|
||||||
@@ -320,7 +320,7 @@ was explored using the experimental [Document Picture-in-Picture
|
|||||||
API](https://caniuse.com/mdn-api_documentpictureinpicture), however it proved
|
API](https://caniuse.com/mdn-api_documentpictureinpicture), however it proved
|
||||||
unworkable: Home Assistant state updates cannot reach a card in a separate
|
unworkable: Home Assistant state updates cannot reach a card in a separate
|
||||||
document, and browser-managed styles (`adoptedStyleSheets`) are cleared when
|
document, and browser-managed styles (`adoptedStyleSheets`) are cleared when
|
||||||
elements move between documents — resulting in an unstyled, non-updating card.
|
elements move between documents -- resulting in an unstyled, non-updating card.
|
||||||
|
|
||||||
### New version not working in Chrome
|
### New version not working in Chrome
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ opt <color:#43a047>**""live.microphone.always_connected""**</color> not enabled
|
|||||||
end
|
end
|
||||||
|
|
||||||
note over User, Mic
|
note over User, Mic
|
||||||
The microphone must connect for the call to proceed —
|
The microphone must connect for the call to proceed --
|
||||||
if access is denied or unsupported, the call does not start
|
if access is denied or unsupported, the call does not start
|
||||||
end note
|
end note
|
||||||
|
|
||||||
@@ -46,8 +46,8 @@ end
|
|||||||
|
|
||||||
User -> Card : End the call
|
User -> Card : End the call
|
||||||
note over User, Card
|
note over User, Card
|
||||||
Via Hang-up button, <color:#43a047>**""call_end""**</color> action, or — when
|
Via Hang-up button, <color:#43a047>**""call_end""**</color> action, or -- when
|
||||||
<color:#43a047>**""live.controls.call.lock""**</color> is disabled — navigating away
|
<color:#43a047>**""live.controls.call.lock""**</color> is disabled -- navigating away
|
||||||
end note
|
end note
|
||||||
|
|
||||||
Card --> User : Inbound audio mutes (<color:#43a047>**""live.auto_mute""**</color>)
|
Card --> User : Inbound audio mutes (<color:#43a047>**""live.auto_mute""**</color>)
|
||||||
|
|||||||
@@ -13,6 +13,39 @@ const compat = new FlatCompat({
|
|||||||
allConfig: js.configs.all,
|
allConfig: js.configs.all,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Local rule: ban the em-dash character (U+2014) anywhere, including comments and
|
||||||
|
// strings. The codebase uses a colon or a double hyphen `--` instead. The regex uses
|
||||||
|
// the unicode escape so the character never appears literally in this file.
|
||||||
|
const noEmDash = {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: { description: 'Disallow the em-dash character; use ":" or "--" instead.' },
|
||||||
|
messages: {
|
||||||
|
emDash: 'Em-dash character is not allowed; use ":" or "--" instead.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||||
|
return {
|
||||||
|
Program(node) {
|
||||||
|
const text = sourceCode.getText();
|
||||||
|
const re = /\u2014/g;
|
||||||
|
let match;
|
||||||
|
while ((match = re.exec(text)) !== null) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
loc: {
|
||||||
|
start: sourceCode.getLocFromIndex(match.index),
|
||||||
|
end: sourceCode.getLocFromIndex(match.index + 1),
|
||||||
|
},
|
||||||
|
messageId: 'emDash',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
{
|
{
|
||||||
extends: compat.extends('plugin:@typescript-eslint/recommended', 'prettier'),
|
extends: compat.extends('plugin:@typescript-eslint/recommended', 'prettier'),
|
||||||
@@ -23,8 +56,56 @@ export default defineConfig([
|
|||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
plugins: {
|
||||||
|
local: { rules: { 'no-em-dash': noEmDash } },
|
||||||
|
},
|
||||||
|
|
||||||
rules: {
|
rules: {
|
||||||
curly: 'error',
|
curly: 'error',
|
||||||
|
'local/no-em-dash': 'error',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||||
|
'@typescript-eslint/parameter-properties': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Timers must go through the Timer class (src/utils/timer.ts).
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
'CallExpression > Identifier.callee[name=/^(setTimeout|setInterval)$/]',
|
||||||
|
message:
|
||||||
|
'Use the Timer class (src/utils/timer.ts) instead of setTimeout/setInterval.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'MemberExpression[property.name=/^(setTimeout|setInterval)$/]',
|
||||||
|
message:
|
||||||
|
'Use the Timer class (src/utils/timer.ts) instead of window.setTimeout/setInterval.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// A fire-and-forget promise must be marked with `void` to show it is deliberate.
|
||||||
|
// To know which calls return a promise, this rule needs type information, so the
|
||||||
|
// `project` option below runs the TypeScript type-checker over source while linting.
|
||||||
|
// That makes linting source slower. Tests are excluded: they leave promises unawaited
|
||||||
|
// freely.
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
project: './tsconfig.json',
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# `yarn deployment-update` — point the manual-test Home Assistant instance at
|
# `yarn deployment-update` -- point the manual-test Home Assistant instance at
|
||||||
# this worktree.
|
# this worktree.
|
||||||
#
|
#
|
||||||
# Repoints the `$ACC_LIVE_LINK` symlink (the dev static server's document root)
|
# Repoints the `$ACC_LIVE_LINK` symlink (the dev static server's document root)
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [[ -z "${ACC_LIVE_LINK:-}" ]]; then
|
if [[ -z "${ACC_LIVE_LINK:-}" ]]; then
|
||||||
echo "ACC_LIVE_LINK is not set — point it at the symlink to update." >&2
|
echo "ACC_LIVE_LINK is not set -- point it at the symlink to update." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -18,4 +18,4 @@ mkdir -p "$(dirname "$ACC_LIVE_LINK")"
|
|||||||
ln -sfn "$TARGET" "$ACC_LIVE_LINK"
|
ln -sfn "$TARGET" "$ACC_LIVE_LINK"
|
||||||
|
|
||||||
echo "Advanced Camera Card → $TARGET"
|
echo "Advanced Camera Card → $TARGET"
|
||||||
[[ -d "$TARGET" ]] || echo "(no dist/ here yet — run \`yarn start\` to build it)"
|
[[ -d "$TARGET" ]] || echo "(no dist/ here yet -- run \`yarn start\` to build it)"
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ export class EffectAction extends AdvancedCameraCardAction<EffectActionConfig> {
|
|||||||
const action = this._getAction();
|
const action = this._getAction();
|
||||||
switch (action.effect_action) {
|
switch (action.effect_action) {
|
||||||
case 'start':
|
case 'start':
|
||||||
api.getEffectsManager().startEffect(action.effect);
|
void api.getEffectsManager().startEffect(action.effect);
|
||||||
break;
|
break;
|
||||||
case 'stop':
|
case 'stop':
|
||||||
api.getEffectsManager().stopEffect(action.effect);
|
api.getEffectsManager().stopEffect(action.effect);
|
||||||
break;
|
break;
|
||||||
case 'toggle':
|
case 'toggle':
|
||||||
api.getEffectsManager().toggleEffect(action.effect);
|
void api.getEffectsManager().toggleEffect(action.effect);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export class PTZControlsAction extends AdvancedCameraCardAction<PTZControlsActio
|
|||||||
|
|
||||||
// If `enabled` is explicit, use it. If only `type` is being changed, leave
|
// If `enabled` is explicit, use it. If only `type` is being changed, leave
|
||||||
// `enabled` untouched (undefined = no change). Otherwise (neither set),
|
// `enabled` untouched (undefined = no change). Otherwise (neither set),
|
||||||
// toggle the current enabled value — this is the menu-button show/hide use
|
// toggle the current enabled value -- this is the menu-button show/hide use
|
||||||
// case.
|
// case.
|
||||||
const enabled =
|
const enabled =
|
||||||
action.enabled ??
|
action.enabled ??
|
||||||
|
|||||||
@@ -28,9 +28,8 @@ export class PTZMultiAction extends AdvancedCameraCardAction<PTZMultiActionConfi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(type === 'ptz'
|
void (
|
||||||
? this._toPTZAction(targetID)
|
type === 'ptz' ? this._toPTZAction(targetID) : this._toPTZDigitalAction(targetID)
|
||||||
: this._toPTZDigitalAction(targetID)
|
|
||||||
).execute(api);
|
).execute(api);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,6 @@ export class AutomationsManager {
|
|||||||
--this._nestedAutomationExecutions;
|
--this._nestedAutomationExecutions;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
runActions(automation.actions);
|
void runActions(automation.actions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ export class CallManager {
|
|||||||
restoreView &&
|
restoreView &&
|
||||||
(previousView.view !== 'live' || previousView.camera !== call.cameraID)
|
(previousView.view !== 'live' || previousView.camera !== call.cameraID)
|
||||||
) {
|
) {
|
||||||
viewManager.setViewByParametersWithExistingQuery({
|
void viewManager.setViewByParametersWithExistingQuery({
|
||||||
baseView: previousView,
|
baseView: previousView,
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export abstract class GeneratedTone implements Tone {
|
|||||||
// a stopped tone can never re-arm its loop.
|
// a stopped tone can never re-arm its loop.
|
||||||
protected _scheduleNext(intervalSeconds: number): void {
|
protected _scheduleNext(intervalSeconds: number): void {
|
||||||
/* istanbul ignore next: defensive guard against a subclass calling
|
/* istanbul ignore next: defensive guard against a subclass calling
|
||||||
_scheduleNext after stop() — JS single-threading makes this unreachable
|
_scheduleNext after stop() -- JS single-threading makes this unreachable
|
||||||
from the existing subclasses -- @preserve */
|
from the existing subclasses -- @preserve */
|
||||||
if (!this._context) {
|
if (!this._context) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ export class CardElementManager {
|
|||||||
// These initializers are called when the config is updated, but on initial
|
// These initializers are called when the config is updated, but on initial
|
||||||
// creation of the card hass is not yet available when the config is first
|
// creation of the card hass is not yet available when the config is first
|
||||||
// loaded.
|
// loaded.
|
||||||
this._api.getDefaultManager().initialize();
|
void this._api.getDefaultManager().initialize();
|
||||||
this._api.getMediaPlayerManager().initialize();
|
void this._api.getMediaPlayerManager().initialize();
|
||||||
|
|
||||||
this._api
|
this._api
|
||||||
.getHASSManager()
|
.getHASSManager()
|
||||||
@@ -184,7 +184,7 @@ export class CardElementManager {
|
|||||||
this._api.getFullscreenManager().disconnect();
|
this._api.getFullscreenManager().disconnect();
|
||||||
this._api.getPIPManager().uninitialize();
|
this._api.getPIPManager().uninitialize();
|
||||||
this._api.getKeyboardStateManager().uninitialize();
|
this._api.getKeyboardStateManager().uninitialize();
|
||||||
this._api.getActionsManager().uninitialize();
|
void this._api.getActionsManager().uninitialize();
|
||||||
this._api.getInteractionManager().uninitialize();
|
this._api.getInteractionManager().uninitialize();
|
||||||
this._api.getDefaultManager().uninitialize();
|
this._api.getDefaultManager().uninitialize();
|
||||||
this._api.getHASSManager().getStateWatcher()?.unsubscribe(this.update);
|
this._api.getHASSManager().getStateWatcher()?.unsubscribe(this.update);
|
||||||
@@ -200,7 +200,7 @@ export class CardElementManager {
|
|||||||
this._api
|
this._api
|
||||||
.getInitializationManager()
|
.getInitializationManager()
|
||||||
.uninitialize(InitializationAspect.INITIAL_TRIGGER);
|
.uninitialize(InitializationAspect.INITIAL_TRIGGER);
|
||||||
this._api.getCameraManager().destroy();
|
void this._api.getCameraManager().destroy();
|
||||||
|
|
||||||
this._element.removeEventListener(
|
this._element.removeEventListener(
|
||||||
'mousemove',
|
'mousemove',
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export class ConfigManager {
|
|||||||
// automations, which destroys associated ConditionsManagers. If a condition
|
// automations, which destroys associated ConditionsManagers. If a condition
|
||||||
// transition (e.g. microphone connect) triggers both a user automation and
|
// transition (e.g. microphone connect) triggers both a user automation and
|
||||||
// an unrelated override, an unconditional reload would delete the
|
// an unrelated override, an unconditional reload would delete the
|
||||||
// automation mid-transition — the freshly created replacement has no prior
|
// automation mid-transition -- the freshly created replacement has no prior
|
||||||
// state, treats the current condition as its baseline, and never fires the
|
// state, treats the current condition as its baseline, and never fires the
|
||||||
// action.
|
// action.
|
||||||
const runIfChanged = <T>(
|
const runIfChanged = <T>(
|
||||||
@@ -189,7 +189,7 @@ export class ConfigManager {
|
|||||||
(config) => [config.cameras, config.cameras_global],
|
(config) => [config.cameras, config.cameras_global],
|
||||||
() => {
|
() => {
|
||||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
|
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
|
||||||
this._api.getCameraManager().destroy();
|
void this._api.getCameraManager().destroy();
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -203,7 +203,7 @@ export class ConfigManager {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
/* async */ this._initializeBackgroundAndUpdate(previousConfig);
|
void this._initializeBackgroundAndUpdate(previousConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export class OverridesManager {
|
|||||||
|
|
||||||
const parseResult = advancedCameraCardConfigSchema.safeParse(output);
|
const parseResult = advancedCameraCardConfigSchema.safeParse(output);
|
||||||
if (!parseResult.success) {
|
if (!parseResult.success) {
|
||||||
// Surface one co-located failure object per Zod issue — path, the value
|
// Surface one co-located failure object per Zod issue -- path, the value
|
||||||
// the user actually wrote, and the most informative "expected" field for
|
// the user actually wrote, and the most informative "expected" field for
|
||||||
// this issue code. Avoids dumping the full merged config (which is
|
// this issue code. Avoids dumping the full merged config (which is
|
||||||
// mostly schema defaults the user never wrote) and keeps the reader from
|
// mostly schema defaults the user never wrote) and keeps the reader from
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export class EffectsManager implements EffectsManagerInterface {
|
|||||||
private _startPendingEffects(): void {
|
private _startPendingEffects(): void {
|
||||||
for (const [name, options] of this._pendingEffects.entries()) {
|
for (const [name, options] of this._pendingEffects.entries()) {
|
||||||
this._pendingEffects.delete(name);
|
this._pendingEffects.delete(name);
|
||||||
this._startEffect(name, options);
|
void this._startEffect(name, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,12 @@ export class ScreenfullFullScreenProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fullscreen) {
|
if (fullscreen) {
|
||||||
screenfull.request(this._api.getCardElementManager().getElement());
|
// A denied request (or an exit when not in fullscreen) leaves the UI
|
||||||
|
// consistent: the 'change' handler only fires on a real transition.
|
||||||
|
// Nothing to act on.
|
||||||
|
screenfull.request(this._api.getCardElementManager().getElement()).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
screenfull.exit();
|
screenfull.exit().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,10 @@ export class WebkitFullScreenProvider
|
|||||||
// clicking the 'X' which then fires this event). That's probably the rare
|
// clicking the 'X' which then fires this event). That's probably the rare
|
||||||
// case though.
|
// case though.
|
||||||
this._playTimer.start(WEBKIT_PLAY_SECONDS, () => {
|
this._playTimer.start(WEBKIT_PLAY_SECONDS, () => {
|
||||||
this._getVideoElement()?.play();
|
// Best-effort resume after a fullscreen exit.
|
||||||
|
this._getVideoElement()
|
||||||
|
?.play()
|
||||||
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class HASSManager implements HASSManagerReadonlyInterface {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
|
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
|
||||||
this._api.getCameraManager().destroy();
|
void this._api.getCameraManager().destroy();
|
||||||
this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW);
|
this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW);
|
||||||
this._api
|
this._api
|
||||||
.getInitializationManager()
|
.getInitializationManager()
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export class InitializationManager {
|
|||||||
if (!this._shouldInitializeMandatory()) {
|
if (!this._shouldInitializeMandatory()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
/* async */ this.initializeMandatory();
|
void this.initializeMandatory();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _shouldInitializeMandatory(): boolean {
|
private _shouldInitializeMandatory(): boolean {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class IssueManager {
|
|||||||
private _suspended = false;
|
private _suspended = false;
|
||||||
|
|
||||||
// Reentrancy guard: evaluate() calls setState() on the condition state
|
// Reentrancy guard: evaluate() calls setState() on the condition state
|
||||||
// manager, which fires listeners synchronously — including the one
|
// manager, which fires listeners synchronously -- including the one
|
||||||
// registered in this constructor. Without this guard, detectDynamic()
|
// registered in this constructor. Without this guard, detectDynamic()
|
||||||
// and presence computation would run twice per evaluation.
|
// and presence computation would run twice per evaluation.
|
||||||
private _evaluating = false;
|
private _evaluating = false;
|
||||||
@@ -165,16 +165,14 @@ export class IssueManager {
|
|||||||
// normal re-evaluation (on any condition-state change).
|
// normal re-evaluation (on any condition-state change).
|
||||||
//
|
//
|
||||||
// `initialized: true` in the change payload means mandatory initialization
|
// `initialized: true` in the change payload means mandatory initialization
|
||||||
// just finished — see InitializationManager._initializeMandatory. That's
|
// just finished -- see InitializationManager._initializeMandatory. That's
|
||||||
// also the earliest point at which the full HASS object is guaranteed
|
// also the earliest point at which the full HASS object is guaranteed
|
||||||
// ready for websocket calls (e.g. LegacyResourceIssue's lovelace/resources
|
// ready for websocket calls (e.g. LegacyResourceIssue's lovelace/resources
|
||||||
// fetch). Because `initialized` is latched (its comment notes it never
|
// fetch). Because `initialized` is latched (its comment notes it never
|
||||||
// changes again), this block fires exactly once per IssueManager life.
|
// changes again), this block fires exactly once per IssueManager life.
|
||||||
private _onStateChange(change: ConditionStateChange): void {
|
private _onStateChange(change: ConditionStateChange): void {
|
||||||
if (change.change.initialized === true && change.new.hass) {
|
if (change.change.initialized === true && change.new.hass) {
|
||||||
/* async */ this._stateManager
|
void this._stateManager.detectStatic(change.new.hass).then(() => this.evaluate());
|
||||||
.detectStatic(change.new.hass)
|
|
||||||
.then(() => this.evaluate());
|
|
||||||
}
|
}
|
||||||
this.evaluate();
|
this.evaluate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export class ConnectionIssue implements Issue {
|
|||||||
private _state: ConnectionState = 'ready';
|
private _state: ConnectionState = 'ready';
|
||||||
|
|
||||||
public detectDynamic(state: ConditionState): void {
|
public detectDynamic(state: ConditionState): void {
|
||||||
// Before HASS is ever provided, leave state untouched — undefined hass is
|
// Before HASS is ever provided, leave state untouched -- undefined hass is
|
||||||
// not a disconnection, just "not yet initialized".
|
// not a disconnection, just "not yet initialized".
|
||||||
if (state.hass === undefined) {
|
if (state.hass === undefined) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export class InitializationIssue extends AbstractErrorIssue {
|
|||||||
// resources (WebSocket subscriptions, listeners) before the CAMERAS
|
// resources (WebSocket subscriptions, listeners) before the CAMERAS
|
||||||
// init aspect replaces the instance via createCameraManager().
|
// init aspect replaces the instance via createCameraManager().
|
||||||
this._api.getInitializationManager().uninitializeMandatory();
|
this._api.getInitializationManager().uninitializeMandatory();
|
||||||
this._api.getCameraManager().destroy();
|
void this._api.getCameraManager().destroy();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export class MediaLoadIssue implements Issue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Explicit trigger — called when a component fires an issue:trigger event.
|
// Explicit trigger -- called when a component fires an issue:trigger event.
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public trigger(context: IssueTriggerContext['media_load']): void {
|
public trigger(context: IssueTriggerContext['media_load']): void {
|
||||||
@@ -45,7 +45,7 @@ export class MediaLoadIssue implements Issue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Detection — called by the manager on every state change.
|
// Detection -- called by the manager on every state change.
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public detectDynamic(state: ConditionState): void {
|
public detectDynamic(state: ConditionState): void {
|
||||||
@@ -62,7 +62,7 @@ export class MediaLoadIssue implements Issue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// State queries — called by the manager to read current state.
|
// State queries -- called by the manager to read current state.
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public hasIssue(): boolean {
|
public hasIssue(): boolean {
|
||||||
@@ -113,7 +113,7 @@ export class MediaLoadIssue implements Issue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Retry — called by the manager to schedule a media reload.
|
// Retry -- called by the manager to schedule a media reload.
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public needsRetry(): boolean {
|
public needsRetry(): boolean {
|
||||||
@@ -144,7 +144,7 @@ export class MediaLoadIssue implements Issue {
|
|||||||
// re-attempts loading underneath. If the retry succeeds,
|
// re-attempts loading underneath. If the retry succeeds,
|
||||||
// _handleMediaLoaded will clear everything when media:loaded fires. If
|
// _handleMediaLoaded will clear everything when media:loaded fires. If
|
||||||
// it fails silently (e.g. bogus stream name), the error stays visible
|
// it fails silently (e.g. bogus stream name), the error stays visible
|
||||||
// immediately — no new 10s grace period.
|
// immediately -- no new 10s grace period.
|
||||||
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
|
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -212,7 +212,7 @@ export class MediaLoadIssue implements Issue {
|
|||||||
this._timerTargetID = targetID;
|
this._timerTargetID = targetID;
|
||||||
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
|
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
|
||||||
// Record the error on timeout so retry() knows which epoch to bump.
|
// Record the error on timeout so retry() knows which epoch to bump.
|
||||||
// targetID is guaranteed non-null here — the null case bails at the
|
// targetID is guaranteed non-null here -- the null case bails at the
|
||||||
// top of _handleMediaNotLoaded.
|
// top of _handleMediaNotLoaded.
|
||||||
this._erroredTargetIDs.add(targetID);
|
this._erroredTargetIDs.add(targetID);
|
||||||
this._activate();
|
this._activate();
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class MediaQueryIssue extends AbstractErrorIssue {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this._error = null;
|
this._error = null;
|
||||||
this._api.getViewManager().setViewByParametersWithNewQuery();
|
void this._api.getViewManager().setViewByParametersWithNewQuery();
|
||||||
|
|
||||||
// Exclusive retry. No other issue should attempt to retry until the next
|
// Exclusive retry. No other issue should attempt to retry until the next
|
||||||
// evaluation cycle, when we'll know if this was successful.
|
// evaluation cycle, when we'll know if this was successful.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { summarizeNotification } from '../../components-lib/notification/summari
|
|||||||
import { ConditionState } from '../../condition-trigger/conditions/types';
|
import { ConditionState } from '../../condition-trigger/conditions/types';
|
||||||
import { Notification } from '../../config/schema/actions/types';
|
import { Notification } from '../../config/schema/actions/types';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
import { isTruthy } from '../../utils/basic';
|
import { errorToConsole, isTruthy } from '../../utils/basic';
|
||||||
import {
|
import {
|
||||||
Issue,
|
Issue,
|
||||||
IssueDescription,
|
IssueDescription,
|
||||||
@@ -27,12 +27,18 @@ export class IssueStateManager implements IssueReadOnlyState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Detection — static (one-shot on init) and dynamic (on every state change).
|
// Detection -- static (one-shot on init) and dynamic (on every state change).
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public async detectStatic(hass: HomeAssistant): Promise<void> {
|
public async detectStatic(hass: HomeAssistant): Promise<void> {
|
||||||
for (const issue of this._issues.values()) {
|
for (const issue of this._issues.values()) {
|
||||||
await issue.detectStatic?.(hass);
|
try {
|
||||||
|
await issue.detectStatic?.(hass);
|
||||||
|
} catch (e) {
|
||||||
|
// Isolate one issue's detection failure so it cannot abort detection
|
||||||
|
// for the rest; log so the cause is visible.
|
||||||
|
errorToConsole(e as Error);
|
||||||
|
}
|
||||||
this._logIfNew(issue);
|
this._logIfNew(issue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +63,7 @@ export class IssueStateManager implements IssueReadOnlyState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Queries — read active issue state.
|
// Queries -- read active issue state.
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public getFullCardIssue(): IssueDescription | null {
|
public getFullCardIssue(): IssueDescription | null {
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ export interface KeyedIssueDescription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map of currently active issues keyed by IssueKey, with each entry's value
|
// Map of currently active issues keyed by IssueKey, with each entry's value
|
||||||
// being the issue's current rendered description. Stored as a Map (not just
|
// being the issue's current rendered description. Stored as a Map (not just a
|
||||||
// a Set of keys) so that sub-state changes within an issue — e.g.
|
// Set of keys) so that sub-state changes within an issue -- e.g.
|
||||||
// ConnectionIssue swapping between 'lost' and 'starting' — are reflected as
|
// ConnectionIssue swapping between 'lost' and 'starting' -- are reflected as
|
||||||
// real value-level diffs to the condition state, triggering re-renders and
|
// real value-level diffs to the condition state, triggering re-renders and any
|
||||||
// any user-defined conditions that depend on issue state.
|
// user-defined conditions that depend on issue state.
|
||||||
export type IssuePresence = Map<IssueKey, IssueDescription>;
|
export type IssuePresence = Map<IssueKey, IssueDescription>;
|
||||||
export interface IssueReadOnlyState {
|
export interface IssueReadOnlyState {
|
||||||
hasFullCardIssue(): boolean;
|
hasFullCardIssue(): boolean;
|
||||||
@@ -80,7 +80,7 @@ export interface Issue {
|
|||||||
// loop (exclusive), false to allow subsequent issues to also retry.
|
// loop (exclusive), false to allow subsequent issues to also retry.
|
||||||
retry?(): boolean;
|
retry?(): boolean;
|
||||||
|
|
||||||
// Optional user-initiated fix. Not called by the issue infrastructure —
|
// Optional user-initiated fix. Not called by the issue infrastructure --
|
||||||
// callers (e.g. notification control actions) invoke this directly.
|
// callers (e.g. notification control actions) invoke this directly.
|
||||||
fix?(hass: HomeAssistant): Promise<boolean>;
|
fix?(hass: HomeAssistant): Promise<boolean>;
|
||||||
|
|
||||||
@@ -92,11 +92,11 @@ export interface Issue {
|
|||||||
|
|
||||||
// Called when the card is detached. Issues with age-based timers (e.g.
|
// Called when the card is detached. Issues with age-based timers (e.g.
|
||||||
// loading-timeout timers) must stop them here so that time spent offscreen
|
// loading-timeout timers) must stop them here so that time spent offscreen
|
||||||
// doesn't count against the user. Must preserve already-active issue state
|
// doesn't count against the user. Must preserve already-active issue state --
|
||||||
// — a full-card issue visible at detach should still be visible on
|
// a full-card issue visible at detach should still be visible on reattach. No
|
||||||
// reattach. No `resume` hook: IssueManager.resume() triggers a normal
|
// `resume` hook: IssueManager.resume() triggers a normal evaluate(), so any
|
||||||
// evaluate(), so any timer that should restart is re-armed via
|
// timer that should restart is re-armed via detectDynamic against the current
|
||||||
// detectDynamic against the current condition state.
|
// condition state.
|
||||||
suspend?(): void;
|
suspend?(): void;
|
||||||
|
|
||||||
// Release external resources (e.g. a listener registered on another manager)
|
// Release external resources (e.g. a listener registered on another manager)
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import type { LockPolicy } from './types';
|
|||||||
// - Stream-stopping / re-init actions: pause, reload, and casting (which
|
// - Stream-stopping / re-init actions: pause, reload, and casting (which
|
||||||
// rehosts the stream to a media player).
|
// rehosts the stream to a media player).
|
||||||
//
|
//
|
||||||
// `call_start` is intentionally absent — it's the entry into the lock.
|
// `call_start` is intentionally absent -- it's the entry into the lock.
|
||||||
// `call_end` is also absent — it dispatches via `setViewByParameters({ force:
|
// `call_end` is also absent -- it dispatches via `setViewByParameters({ force:
|
||||||
// true })` to bypass the lock, so listing it here would be redundant.
|
// true })` to bypass the lock, so listing it here would be redundant.
|
||||||
const CALL_DISRUPTIVE_ACTIONS: ReadonlySet<string> = new Set([
|
const CALL_DISRUPTIVE_ACTIONS: ReadonlySet<string> = new Set([
|
||||||
// View / camera / substream changes.
|
// View / camera / substream changes.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class MediaLoadedInfoManager {
|
|||||||
// never cleared by `clear` / `_clearTarget`, only by `initialize`.
|
// never cleared by `clear` / `_clearTarget`, only by `initialize`.
|
||||||
private _lastKnown: Map<string, MediaLoadedInfo> = new Map();
|
private _lastKnown: Map<string, MediaLoadedInfo> = new Map();
|
||||||
|
|
||||||
// The currently "active" target — the one whose info drives condition state
|
// The currently "active" target -- the one whose info drives condition state
|
||||||
// and card-level side effects. Driven by ViewManager on every view change.
|
// and card-level side effects. Driven by ViewManager on every view change.
|
||||||
private _selected: string | null = null;
|
private _selected: string | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ interface QueryStringViewIntent {
|
|||||||
|
|
||||||
// The substream change to apply alongside the view. Tri-state:
|
// The substream change to apply alongside the view. Tri-state:
|
||||||
// - `undefined`: no substream URL action present, no modifier issued.
|
// - `undefined`: no substream URL action present, no modifier issued.
|
||||||
// - `string`: `substream_on=X` — engage stream X.
|
// - `string`: `substream_on=X` -- engage stream X.
|
||||||
// - `null`: `substream_off` — explicitly clear the override.
|
// - `null`: `substream_off` -- explicitly clear the override.
|
||||||
stream?: string | null;
|
stream?: string | null;
|
||||||
};
|
};
|
||||||
other?: AdvancedCameraCardCustomActionConfig[];
|
other?: AdvancedCameraCardCustomActionConfig[];
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export class ViewManager implements ViewManagerInterface {
|
|||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
// A non-throwing factory call clears any prior view_incompatible /
|
// A non-throwing factory call clears any prior view_incompatible /
|
||||||
// media_query state — ensures a previously-dismissed mid-session popup
|
// media_query state -- ensures a previously-dismissed mid-session popup
|
||||||
// does not linger invisibly and re-pop on the next evaluation cycle,
|
// does not linger invisibly and re-pop on the next evaluation cycle,
|
||||||
// and that a stale media_query failure from an abandoned gallery /
|
// and that a stale media_query failure from an abandoned gallery /
|
||||||
// viewer doesn't follow the user into an unrelated view.
|
// viewer doesn't follow the user into an unrelated view.
|
||||||
@@ -361,7 +361,7 @@ export class ViewManager implements ViewManagerInterface {
|
|||||||
if (!this._api.getQueryStringManager().hasViewRelatedActionsToRun()) {
|
if (!this._api.getQueryStringManager().hasViewRelatedActionsToRun()) {
|
||||||
// This is not awaited to allow the initialization to complete before the
|
// This is not awaited to allow the initialization to complete before the
|
||||||
// query is answered.
|
// query is answered.
|
||||||
this.setViewDefaultWithNewQuery({ failSafe: true });
|
void this.setViewDefaultWithNewQuery({ failSafe: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -329,7 +329,7 @@ class AdvancedCameraCard extends LitElement {
|
|||||||
|
|
||||||
protected updated(): void {
|
protected updated(): void {
|
||||||
if (this._controller.getInitializationManager().isInitializedMandatory()) {
|
if (this._controller.getInitializationManager().isInitializedMandatory()) {
|
||||||
this._controller.getQueryStringManager().executeIfNecessary();
|
void this._controller.getQueryStringManager().executeIfNecessary();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export class MicrophoneActionsController {
|
|||||||
}
|
}
|
||||||
this._callAnswered = answered;
|
this._callAnswered = answered;
|
||||||
if (answered) {
|
if (answered) {
|
||||||
this._unmuteIfConfigured('call');
|
void this._unmuteIfConfigured('call');
|
||||||
} else {
|
} else {
|
||||||
this._muteIfConfigured('call');
|
this._muteIfConfigured('call');
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,10 @@ export class MicrophoneActionsController {
|
|||||||
this._options?.microphoneManager &&
|
this._options?.microphoneManager &&
|
||||||
this._options.autoUnmuteConditions?.includes(condition)
|
this._options.autoUnmuteConditions?.includes(condition)
|
||||||
) {
|
) {
|
||||||
await this._options.microphoneManager.unmute();
|
// A denied or missing microphone already shows in the UI: the menu
|
||||||
|
// microphone button switches to its forbidden icon. A failed auto-unmute
|
||||||
|
// has nothing more to act on.
|
||||||
|
await this._options.microphoneManager.unmute().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export class MediaActionsController {
|
|||||||
public setMicrophoneState(state: MicrophoneState): void {
|
public setMicrophoneState(state: MicrophoneState): void {
|
||||||
const previous = this._microphoneState;
|
const previous = this._microphoneState;
|
||||||
this._microphoneState = state;
|
this._microphoneState = state;
|
||||||
this._microphoneStateChangeHandler(previous, state);
|
void this._microphoneStateChangeHandler(previous, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio-out auto-mute/unmute driven by call answer: unmute when the call
|
// Audio-out auto-mute/unmute driven by call answer: unmute when the call
|
||||||
@@ -85,10 +85,10 @@ export class MediaActionsController {
|
|||||||
this._callAnswered = answered;
|
this._callAnswered = answered;
|
||||||
if (answered) {
|
if (answered) {
|
||||||
this._pendingCallStartAction = true;
|
this._pendingCallStartAction = true;
|
||||||
this._applyPendingCallStartAction();
|
void this._applyPendingCallStartAction();
|
||||||
} else {
|
} else {
|
||||||
this._pendingCallStartAction = false;
|
this._pendingCallStartAction = false;
|
||||||
this._muteTargetIfConfigured('call');
|
void this._muteTargetIfConfigured('call');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ export class MediaFilterController {
|
|||||||
const queryCameraIDs = query.getAllCameraIDs();
|
const queryCameraIDs = query.getAllCameraIDs();
|
||||||
const cameraID = queryCameraIDs.size === 1 ? [...queryCameraIDs][0] : undefined;
|
const cameraID = queryCameraIDs.size === 1 ? [...queryCameraIDs][0] : undefined;
|
||||||
|
|
||||||
this._viewManager?.setViewByParametersWithExistingQuery({
|
void this._viewManager?.setViewByParametersWithExistingQuery({
|
||||||
params: {
|
params: {
|
||||||
query,
|
query,
|
||||||
// If single camera, set it as the active camera for menu navigation
|
// If single camera, set it as the active camera for menu navigation
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface MediaLoadedInfoSinkConfig {
|
|||||||
// notification.
|
// notification.
|
||||||
getTargetID: () => string | null;
|
getTargetID: () => string | null;
|
||||||
|
|
||||||
// Fires when the active info changes — i.e. when the active target's entry
|
// Fires when the active info changes -- i.e. when the active target's entry
|
||||||
// transitions (load arrives, abort, or selection changes the active entry).
|
// transitions (load arrives, abort, or selection changes the active entry).
|
||||||
// Loads for other targets are cached but do not fire the callback.
|
// Loads for other targets are cached but do not fire the callback.
|
||||||
callback?: (info: MediaLoadedInfo | null) => void;
|
callback?: (info: MediaLoadedInfo | null) => void;
|
||||||
@@ -28,7 +28,7 @@ interface MediaLoadedInfoSinkConfig {
|
|||||||
* user selects a slide whose media has already loaded, the sink immediately
|
* user selects a slide whose media has already loaded, the sink immediately
|
||||||
* exposes the cached entry.
|
* exposes the cached entry.
|
||||||
*
|
*
|
||||||
* Lifecycle asymmetry — `callback` fires on:
|
* Lifecycle asymmetry -- `callback` fires on:
|
||||||
* - the active target's load arrival,
|
* - the active target's load arrival,
|
||||||
* - the active target's source aborting (with `null`), and
|
* - the active target's source aborting (with `null`), and
|
||||||
* - selection changing to / from a target whose active info differs.
|
* - selection changing to / from a target whose active info differs.
|
||||||
@@ -46,7 +46,7 @@ export class MediaLoadedInfoSinkController implements ReactiveController {
|
|||||||
// stale abort can't blow away an entry that's since been overwritten.
|
// stale abort can't blow away an entry that's since been overwritten.
|
||||||
private _byTarget = new Map<string, MediaLoadedInfo>();
|
private _byTarget = new Map<string, MediaLoadedInfo>();
|
||||||
|
|
||||||
// The targetID whose info we last surfaced — drives `hostUpdated` change
|
// The targetID whose info we last surfaced -- drives `hostUpdated` change
|
||||||
// detection. `_lastActiveInfo` records what the callback last saw, so we
|
// detection. `_lastActiveInfo` records what the callback last saw, so we
|
||||||
// don't fire it for no-op selection changes (e.g. selection changes but
|
// don't fire it for no-op selection changes (e.g. selection changes but
|
||||||
// both old and new are null/loaded with the same info reference).
|
// both old and new are null/loaded with the same info reference).
|
||||||
@@ -67,7 +67,7 @@ export class MediaLoadedInfoSinkController implements ReactiveController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public hostUpdated(): void {
|
public hostUpdated(): void {
|
||||||
// Detect selection changes — `getTargetID` is owned by the host and may
|
// Detect selection changes -- `getTargetID` is owned by the host and may
|
||||||
// flip when its props change (carousel slide change, view change, etc.).
|
// flip when its props change (carousel slide change, view change, etc.).
|
||||||
const newID = this._config.getTargetID();
|
const newID = this._config.getTargetID();
|
||||||
if (newID !== this._lastActiveTargetID) {
|
if (newID !== this._lastActiveTargetID) {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
|
|||||||
private _abort: AbortController | null = null;
|
private _abort: AbortController | null = null;
|
||||||
|
|
||||||
// Survives disconnect so we can re-dispatch on reconnect. Only ever holds
|
// Survives disconnect so we can re-dispatch on reconnect. Only ever holds
|
||||||
// info validated by `set` — i.e., always has a targetID.
|
// info validated by `set` -- i.e., always has a targetID.
|
||||||
private _lastSet: TargetedMediaLoadedInfo | null = null;
|
private _lastSet: TargetedMediaLoadedInfo | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -67,7 +67,7 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
|
|||||||
|
|
||||||
public hostConnected(): void {
|
public hostConnected(): void {
|
||||||
// Two early-returns:
|
// Two early-returns:
|
||||||
// - `!_lastSet`: nothing to replay — either the host has never seen a
|
// - `!_lastSet`: nothing to replay -- either the host has never seen a
|
||||||
// media load or the cache was discarded as stale on a prior reconnect
|
// media load or the cache was discarded as stale on a prior reconnect
|
||||||
// (see below).
|
// (see below).
|
||||||
// - `_abort` non-null: a dispatch is already live, meaning we're already
|
// - `_abort` non-null: a dispatch is already live, meaning we're already
|
||||||
@@ -79,7 +79,7 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revalidate against the current targetID — the host's property may have
|
// Revalidate against the current targetID -- the host's property may have
|
||||||
// flipped while we were disconnected. Replaying the cached info under a
|
// flipped while we were disconnected. Replaying the cached info under a
|
||||||
// stale targetID would misregister with the manager.
|
// stale targetID would misregister with the manager.
|
||||||
if (this._lastSet.targetID === this._config.getTargetID()) {
|
if (this._lastSet.targetID === this._config.getTargetID()) {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const navigateUp = (options?: FolderNavigationParamaters | null): void =>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
options?.viewManagerEpoch.manager.setViewByParametersWithExistingQuery({
|
void options?.viewManagerEpoch.manager.setViewByParametersWithExistingQuery({
|
||||||
params: { query },
|
params: { query },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -77,7 +77,7 @@ export const navigateToFolder = (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
options?.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
void options?.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||||
params: { query },
|
params: { query },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class PTZDragController implements ReactiveController {
|
|||||||
// continuous mode. Below that threshold, drag-end dispatches relative.
|
// continuous mode. Below that threshold, drag-end dispatches relative.
|
||||||
private _continuous = false;
|
private _continuous = false;
|
||||||
|
|
||||||
// When a pinch occurs mid-drag, the drag is "poisoned" — all remaining
|
// When a pinch occurs mid-drag, the drag is "poisoned" -- all remaining
|
||||||
// drag events for that gesture are ignored to prevent stray pan/tilt.
|
// drag events for that gesture are ignored to prevent stray pan/tilt.
|
||||||
private _dragCancelledByPinch = false;
|
private _dragCancelledByPinch = false;
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export class SignedURLController implements ReactiveController {
|
|||||||
|
|
||||||
// When the endpoint requires signing or proxying, the URL must go through
|
// When the endpoint requires signing or proxying, the URL must go through
|
||||||
// the async resolution path. For proxied URLs, under no circumstances
|
// the async resolution path. For proxied URLs, under no circumstances
|
||||||
// should we fall back to returning the unproxied URL — doing so risks
|
// should we fall back to returning the unproxied URL -- doing so risks
|
||||||
// leaking traffic or causing mixed-content errors.
|
// leaking traffic or causing mixed-content errors.
|
||||||
if (options.proxyConfig?.enabled || options.endpoint?.sign) {
|
if (options.proxyConfig?.enabled || options.endpoint?.sign) {
|
||||||
return this._value;
|
return this._value;
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ export class TimelineController {
|
|||||||
this._setTargetBarAppropriately(targetTime);
|
this._setTargetBarAppropriately(targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._throttledSetViewDuringRangeChange(targetTime, properties);
|
void this._throttledSetViewDuringRangeChange(targetTime, properties);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
() => dispatchMediaPlayEvent(this),
|
() => dispatchMediaPlayEvent(this),
|
||||||
() => dispatchMediaPauseEvent(this),
|
() => dispatchMediaPauseEvent(this),
|
||||||
// Clear image load errors on each timer tick so the next render retries
|
// Clear image load errors on each timer tick so the next render retries
|
||||||
// the <img> — but only for modes where the underlying URL genuinely
|
// the <img> -- but only for modes where the underlying URL genuinely
|
||||||
// changes between ticks (camera/entity snapshots). For mode: url, the
|
// changes between ticks (camera/entity snapshots). For mode: url, the
|
||||||
// same static URL will fail the same way every time, so clearing the
|
// same static URL will fail the same way every time, so clearing the
|
||||||
// error just causes a visible flicker (notification → blank <img> →
|
// error just causes a visible flicker (notification → blank <img> →
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
|
|
||||||
private _setViewCameraID(cameraID?: string | null): void {
|
private _setViewCameraID(cameraID?: string | null): void {
|
||||||
if (cameraID) {
|
if (cameraID) {
|
||||||
this.viewManagerEpoch?.manager.setViewByParametersWithNewQuery({
|
void this.viewManagerEpoch?.manager.setViewByParametersWithNewQuery({
|
||||||
params: {
|
params: {
|
||||||
camera: cameraID,
|
camera: cameraID,
|
||||||
},
|
},
|
||||||
@@ -319,9 +319,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
const controller = this._mediaLoadedInfoSinkController.get()?.mediaPlayerController;
|
const controller = this._mediaLoadedInfoSinkController.get()?.mediaPlayerController;
|
||||||
// Fire-and-forget; the `volumechange` event drives the re-render.
|
// Fire-and-forget; the `volumechange` event drives the re-render.
|
||||||
if (controller?.isMuted()) {
|
if (controller?.isMuted()) {
|
||||||
controller.unmute();
|
void controller.unmute();
|
||||||
} else {
|
} else {
|
||||||
controller?.mute();
|
void controller?.mute();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +478,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
const selectedCameraIndex = this._getSelectedCameraIndex();
|
const selectedCameraIndex = this._getSelectedCameraIndex();
|
||||||
|
|
||||||
if (this.viewFilterCameraID) {
|
if (this.viewFilterCameraID) {
|
||||||
this._mediaActionsController.setTarget(
|
void this._mediaActionsController.setTarget(
|
||||||
selectedCameraIndex,
|
selectedCameraIndex,
|
||||||
// Camera in this carousel is only selected if the camera from the
|
// Camera in this carousel is only selected if the camera from the
|
||||||
// view matches the filtered camera.
|
// view matches the filtered camera.
|
||||||
@@ -486,7 +486,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Carousel is not filtered, so the targeted camera is always selected.
|
// Carousel is not filtered, so the targeted camera is always selected.
|
||||||
this._mediaActionsController.setTarget(selectedCameraIndex, true);
|
void this._mediaActionsController.setTarget(selectedCameraIndex, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._mediaHeightController.setSelected(selectedCameraIndex);
|
this._mediaHeightController.setSelected(selectedCameraIndex);
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
|
|||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) {
|
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) {
|
||||||
import('../media-grid.js');
|
void import('../media-grid.js');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export class AdvancedCameraCardLive extends LitElement {
|
|||||||
// from a hidden live view. Treat the live view as having no selected
|
// from a hidden live view. Treat the live view as having no selected
|
||||||
// camera unless it is the active view.
|
// camera unless it is the active view.
|
||||||
const view = this.viewManagerEpoch?.manager.getView();
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
this._microphoneActionsController.setSelectedCamera(
|
void this._microphoneActionsController.setSelectedCamera(
|
||||||
view?.is('live') ? view.camera ?? null : null,
|
view?.is('live') ? view.camera ?? null : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
|||||||
// VideoRTC owns the transition: it updates microphoneStream, swaps the
|
// VideoRTC owns the transition: it updates microphoneStream, swaps the
|
||||||
// track on the pre-armed transceiver, and validates against stale async
|
// track on the pre-armed transceiver, and validates against stale async
|
||||||
// completions before any reconnect fallback. Fire-and-forget is fine.
|
// completions before any reconnect fallback. Fire-and-forget is fine.
|
||||||
/* async */ this._player.setMicrophoneStream(this.microphoneStream ?? null);
|
void this._player.setMicrophoneStream(this.microphoneStream ?? null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ export class VideoRTC extends HTMLElement {
|
|||||||
/**
|
/**
|
||||||
* Owns the microphone stream transition end-to-end: updates the property,
|
* Owns the microphone stream transition end-to-end: updates the property,
|
||||||
* extracts the outbound audio track, and swaps it onto the pre-armed audio
|
* extracts the outbound audio track, and swaps it onto the pre-armed audio
|
||||||
* transceiver via `replaceTrack` — no SDP renegotiation, no visible reload.
|
* transceiver via `replaceTrack` -- no SDP renegotiation, no visible reload.
|
||||||
*
|
*
|
||||||
* Falls back to a full reconnect if `replaceTrack` rejects, but only when
|
* Falls back to a full reconnect if `replaceTrack` rejects, but only when
|
||||||
* the rejection still describes the current desired state. The transceiver
|
* the rejection still describes the current desired state. The transceiver
|
||||||
@@ -410,8 +410,8 @@ export class VideoRTC extends HTMLElement {
|
|||||||
*/
|
*/
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
// Synchronous manager-side cleanup by aborting the load's signal. The
|
// Synchronous manager-side cleanup by aborting the load's signal. The
|
||||||
// signal's abort listeners — registered by the card-root listener and
|
// signal's abort listeners -- registered by the card-root listener and
|
||||||
// any sinks in the bubble path — fire even though `parentNode` is
|
// any sinks in the bubble path -- fire even though `parentNode` is
|
||||||
// already null, because abort is plain JS, not DOM-event-bound.
|
// already null, because abort is plain JS, not DOM-event-bound.
|
||||||
this._abortController?.abort();
|
this._abortController?.abort();
|
||||||
this._abortController = null;
|
this._abortController = null;
|
||||||
@@ -833,7 +833,7 @@ export class VideoRTC extends HTMLElement {
|
|||||||
// Always pre-arm a single outbound audio transceiver so the SDP advertises
|
// Always pre-arm a single outbound audio transceiver so the SDP advertises
|
||||||
// the slot from the start. With the slot in place, the mic track can be
|
// the slot from the start. With the slot in place, the mic track can be
|
||||||
// attached/detached later via `setMicrophoneStream` (replaceTrack) without
|
// attached/detached later via `setMicrophoneStream` (replaceTrack) without
|
||||||
// renegotiating — avoiding a visible reload of this cell each time grid
|
// renegotiating -- avoiding a visible reload of this cell each time grid
|
||||||
// selection moves the mic between cameras.
|
// selection moves the mic between cameras.
|
||||||
//
|
//
|
||||||
// Pure SDP allocation: the kind-only `addTransceiver('audio', ...)` form
|
// Pure SDP allocation: the kind-only `addTransceiver('audio', ...)` form
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
|
|||||||
public updated(): void {
|
public updated(): void {
|
||||||
// Extract the video component after it has been rendered and generate the
|
// Extract the video component after it has been rendered and generate the
|
||||||
// media load event.
|
// media load event.
|
||||||
this.updateComplete.then(() => {
|
void this.updateComplete.then(() => {
|
||||||
this._videoRTC = this.renderRoot?.querySelector('#webrtc') ?? null;
|
this._videoRTC = this.renderRoot?.querySelector('#webrtc') ?? null;
|
||||||
const video = this._getVideo();
|
const video = this._getVideo();
|
||||||
if (video) {
|
if (video) {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export class AdvancedCameraCardLoading extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _startEffect(effect: EffectName): void {
|
private _startEffect(effect: EffectName): void {
|
||||||
this.effectsManager?.startEffect(effect, { fadeIn: false });
|
void this.effectsManager?.startEffect(effect, { fadeIn: false });
|
||||||
this._effectName = effect;
|
this._effectName = effect;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
|||||||
this.cameraManager,
|
this.cameraManager,
|
||||||
this.foldersManager,
|
this.foldersManager,
|
||||||
);
|
);
|
||||||
this._mediaFilterController.computeMetadataOptions(this.cameraManager);
|
void this._mediaFilterController.computeMetadataOptions(this.cameraManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The first time the viewManager is set, compute the initial default selections.
|
// The first time the viewManager is set, compute the initial default selections.
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this._optionTitles) {
|
if (!this._optionTitles) {
|
||||||
this._refreshOptionTitles();
|
void this._refreshOptionTitles();
|
||||||
}
|
}
|
||||||
|
|
||||||
const entityID = this.submenuSelect.entity;
|
const entityID = this.submenuSelect.entity;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export class AdvancedCameraCardSurround extends LitElement {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
protected willUpdate(_changedProperties: PropertyValues): void {
|
protected willUpdate(_changedProperties: PropertyValues): void {
|
||||||
if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') {
|
if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') {
|
||||||
import('./timeline-core.js');
|
void import('./timeline-core.js');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export class AdvancedCameraCardThumbnailFeatureThumbnail extends LitElement {
|
|||||||
this._embedThumbnailTask?.status === TaskStatus.INITIAL &&
|
this._embedThumbnailTask?.status === TaskStatus.INITIAL &&
|
||||||
entries.some((entry) => entry.isIntersecting)
|
entries.some((entry) => entry.isIntersecting)
|
||||||
) {
|
) {
|
||||||
this._embedThumbnailTask?.run();
|
void this._embedThumbnailTask?.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
|
|||||||
this._controller.setView(this.viewManagerEpoch ?? null),
|
this._controller.setView(this.viewManagerEpoch ?? null),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this._controller.setView(this.viewManagerEpoch ?? null);
|
void this._controller.setView(this.viewManagerEpoch ?? null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
|||||||
(this._selected !== null && this._media?.[this._selected]?.getID()) || null,
|
(this._selected !== null && this._media?.[this._selected]?.getID()) || null,
|
||||||
callback: () => {
|
callback: () => {
|
||||||
this._mediaHeightController.recalculate();
|
this._mediaHeightController.recalculate();
|
||||||
this._seekHandler();
|
void this._seekHandler();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -388,7 +388,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
|||||||
?.context?.mediaViewer?.seek?.getTime() !==
|
?.context?.mediaViewer?.seek?.getTime() !==
|
||||||
this.viewManagerEpoch?.oldView?.context?.mediaViewer?.seek?.getTime()
|
this.viewManagerEpoch?.oldView?.context?.mediaViewer?.seek?.getTime()
|
||||||
) {
|
) {
|
||||||
this._seekHandler();
|
void this._seekHandler();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,7 +397,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
|||||||
if (!this._media?.length || this._selected === null) {
|
if (!this._media?.length || this._selected === null) {
|
||||||
this._mediaActionsController.unsetTarget();
|
this._mediaActionsController.unsetTarget();
|
||||||
} else {
|
} else {
|
||||||
this._mediaActionsController.setTarget(
|
void this._mediaActionsController.setTarget(
|
||||||
this._selected,
|
this._selected,
|
||||||
// Camera in this carousel is only selected if the camera from the view
|
// Camera in this carousel is only selected if the camera from the view
|
||||||
// matches the filtered camera.
|
// matches the filtered camera.
|
||||||
@@ -438,16 +438,16 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
|||||||
const seekTimeInMedia = selectedMedia.includesTime(seek);
|
const seekTimeInMedia = selectedMedia.includesTime(seek);
|
||||||
this.toggleAttribute('unseekable', !seekTimeInMedia);
|
this.toggleAttribute('unseekable', !seekTimeInMedia);
|
||||||
if (!seekTimeInMedia && !mediaPlayerController.isPaused()) {
|
if (!seekTimeInMedia && !mediaPlayerController.isPaused()) {
|
||||||
mediaPlayerController.pause();
|
void mediaPlayerController.pause();
|
||||||
} else if (seekTimeInMedia && mediaPlayerController.isPaused()) {
|
} else if (seekTimeInMedia && mediaPlayerController.isPaused()) {
|
||||||
mediaPlayerController.play();
|
void mediaPlayerController.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
const seekTime =
|
const seekTime =
|
||||||
(await this.cameraManager?.getMediaSeekTime(selectedMedia, seek)) ?? null;
|
(await this.cameraManager?.getMediaSeekTime(selectedMedia, seek)) ?? null;
|
||||||
|
|
||||||
if (seekTime !== null) {
|
if (seekTime !== null) {
|
||||||
mediaPlayerController.seek(seekTime);
|
void mediaPlayerController.seek(seekTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
|
|||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) {
|
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) {
|
||||||
import('../media-grid.js');
|
void import('../media-grid.js');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,11 +157,11 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
changedProps.has('resolvedMediaCache') ||
|
changedProps.has('resolvedMediaCache') ||
|
||||||
changedProps.has('hass')
|
changedProps.has('hass')
|
||||||
) {
|
) {
|
||||||
this._resolveURL();
|
void this._resolveURL();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) {
|
if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) {
|
||||||
import('../zoomer.js');
|
void import('../zoomer.js');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +290,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
|||||||
.targetID=${mediaID}
|
.targetID=${mediaID}
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
if (this.viewerConfig?.snapshot_click_plays_clip) {
|
if (this.viewerConfig?.snapshot_click_plays_clip) {
|
||||||
this._switchToRelatedClipView();
|
void this._switchToRelatedClipView();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
></advanced-camera-card-image-player>`}
|
></advanced-camera-card-image-player>`}
|
||||||
|
|||||||
@@ -90,16 +90,16 @@ export class AdvancedCameraCardViews extends LitElement {
|
|||||||
if (changedProps.has('viewManagerEpoch') || changedProps.has('config')) {
|
if (changedProps.has('viewManagerEpoch') || changedProps.has('config')) {
|
||||||
const view = this.viewManagerEpoch?.manager.getView();
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
if (view?.is('live') || this._shouldLivePreload()) {
|
if (view?.is('live') || this._shouldLivePreload()) {
|
||||||
import('./live/index.js');
|
void import('./live/index.js');
|
||||||
}
|
}
|
||||||
if (view?.isGalleryView()) {
|
if (view?.isGalleryView()) {
|
||||||
import('./gallery/gallery.js');
|
void import('./gallery/gallery.js');
|
||||||
} else if (view?.isViewerView()) {
|
} else if (view?.isViewerView()) {
|
||||||
import('./viewer/index.js');
|
void import('./viewer/index.js');
|
||||||
} else if (view?.is('image')) {
|
} else if (view?.is('image')) {
|
||||||
import('./image.js');
|
void import('./image.js');
|
||||||
} else if (view?.is('timeline')) {
|
} else if (view?.is('timeline')) {
|
||||||
import('./timeline.js');
|
void import('./timeline.js');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ConditionState } from '../conditions/types';
|
|||||||
// Parses a Home Assistant time-period value (a condition/trigger `for:`) to
|
// Parses a Home Assistant time-period value (a condition/trigger `for:`) to
|
||||||
// seconds, matching HA's `cv.time_period`:
|
// seconds, matching HA's `cv.time_period`:
|
||||||
// - a number, or a bare numeric string, is a count of seconds;
|
// - a number, or a bare numeric string, is a count of seconds;
|
||||||
// - a colon string is `HH:MM` or `HH:MM:SS` — HA reads TWO parts as
|
// - a colon string is `HH:MM` or `HH:MM:SS` -- HA reads TWO parts as
|
||||||
// hours:minutes (not minutes:seconds);
|
// hours:minutes (not minutes:seconds);
|
||||||
// - a `{days, hours, minutes, seconds, milliseconds}` dict (each field a
|
// - a `{days, hours, minutes, seconds, milliseconds}` dict (each field a
|
||||||
// number or a numeric string, e.g. once a template field has been rendered).
|
// number or a numeric string, e.g. once a template field has been rendered).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// A value that may be a single string or a list of strings — common across HA
|
// A value that may be a single string or a list of strings -- common across HA
|
||||||
// condition/trigger fields (e.g. `state`, `to`, `entity_id`).
|
// condition/trigger fields (e.g. `state`, `to`, `entity_id`).
|
||||||
export const stringOrArray = z.string().or(z.string().array());
|
export const stringOrArray = z.string().or(z.string().array());
|
||||||
|
|||||||
+9
-5
@@ -278,9 +278,9 @@ import {
|
|||||||
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
|
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
|
||||||
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
|
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
|
||||||
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
|
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
|
||||||
|
CONF_VIEW_TRIGGERS_EVENT_HOLD_SECONDS,
|
||||||
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
|
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
|
||||||
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
|
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
|
||||||
CONF_VIEW_TRIGGERS_EVENT_HOLD_SECONDS,
|
|
||||||
CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS,
|
CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS,
|
||||||
CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS,
|
CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS,
|
||||||
DOCS_URL,
|
DOCS_URL,
|
||||||
@@ -294,7 +294,7 @@ import { HomeAssistant, LovelaceCardEditor } from './ha/types.js';
|
|||||||
import { localize } from './localize/localize.js';
|
import { localize } from './localize/localize.js';
|
||||||
import editorStyle from './scss/editor.scss';
|
import editorStyle from './scss/editor.scss';
|
||||||
import type { CapabilityKey } from './types.js';
|
import type { CapabilityKey } from './types.js';
|
||||||
import { arrayMove, prettifyTitle } from './utils/basic.js';
|
import { arrayMove, errorToConsole, prettifyTitle } from './utils/basic.js';
|
||||||
import { getCameraID } from './utils/camera.js';
|
import { getCameraID } from './utils/camera.js';
|
||||||
import { fireAdvancedCameraCardEvent } from './utils/fire-advanced-camera-card-event.js';
|
import { fireAdvancedCameraCardEvent } from './utils/fire-advanced-camera-card-event.js';
|
||||||
import { getFolderID } from './utils/folder.js';
|
import { getFolderID } from './utils/folder.js';
|
||||||
@@ -1255,9 +1255,13 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
|
|
||||||
protected willUpdate(): void {
|
protected willUpdate(): void {
|
||||||
if (!this._initialized) {
|
if (!this._initialized) {
|
||||||
sideLoadHomeAssistantElements().then(() => {
|
sideLoadHomeAssistantElements()
|
||||||
this._initialized = true;
|
.then(() => {
|
||||||
});
|
this._initialized = true;
|
||||||
|
})
|
||||||
|
// A failure leaves the editor with degraded HA form elements and is
|
||||||
|
// retried on the next update; log it so the cause is at least visible.
|
||||||
|
.catch((e) => errorToConsole(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ export const createProxiedEndpointIfNecessary = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (proxyConfig.dynamic) {
|
if (proxyConfig.dynamic) {
|
||||||
// Strip hash fragment — it's client-side only and not relevant for
|
// Strip hash fragment -- it's client-side only and not relevant for
|
||||||
// proxy pattern matching.
|
// proxy pattern matching.
|
||||||
const url = endpoint.endpoint.split(/#/)[0];
|
const url = endpoint.endpoint.split(/#/)[0];
|
||||||
await addDynamicProxyURL(hass, url, {
|
await addDynamicProxyURL(hass, url, {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { onAbort } from '../utils/abort-signal.js';
|
|||||||
import './ha-hls-player.js';
|
import './ha-hls-player.js';
|
||||||
import './ha-web-rtc-player.js';
|
import './ha-web-rtc-player.js';
|
||||||
|
|
||||||
customElements.whenDefined('ha-camera-stream').then(() => {
|
void customElements.whenDefined('ha-camera-stream').then(() => {
|
||||||
// ========================================================================================
|
// ========================================================================================
|
||||||
// From:
|
// From:
|
||||||
// - https://github.com/home-assistant/frontend/blob/dev/src/data/camera.ts
|
// - https://github.com/home-assistant/frontend/blob/dev/src/data/camera.ts
|
||||||
@@ -56,7 +56,7 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
|
|
||||||
// ha-camera-stream renders up to three inner players (MJPEG / HLS /
|
// ha-camera-stream renders up to three inner players (MJPEG / HLS /
|
||||||
// WebRTC), only one visible. Inner leaves all fire `media:loaded`
|
// WebRTC), only one visible. Inner leaves all fire `media:loaded`
|
||||||
// independently — we suppress those at this boundary (`stopPropagation` in
|
// independently -- we suppress those at this boundary (`stopPropagation` in
|
||||||
// `_captureInnerLoad`), cache the latest per type, and republish the
|
// `_captureInnerLoad`), cache the latest per type, and republish the
|
||||||
// visible one's info via our own source controller in `updated()`.
|
// visible one's info via our own source controller in `updated()`.
|
||||||
private _mediaLoadedInfoPerStream: Record<StreamType, MediaLoadedInfo> = {};
|
private _mediaLoadedInfoPerStream: Record<StreamType, MediaLoadedInfo> = {};
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
} from '../utils/media-info.js';
|
} from '../utils/media-info.js';
|
||||||
import { ConstructableLitElement } from './types.js';
|
import { ConstructableLitElement } from './types.js';
|
||||||
|
|
||||||
customElements.whenDefined('ha-hls-player').then(() => {
|
void customElements.whenDefined('ha-hls-player').then(() => {
|
||||||
const HaHlsPlayer = customElements.get('ha-hls-player') as ConstructableLitElement;
|
const HaHlsPlayer = customElements.get('ha-hls-player') as ConstructableLitElement;
|
||||||
|
|
||||||
@customElement('advanced-camera-card-ha-hls-player')
|
@customElement('advanced-camera-card-ha-hls-player')
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
} from '../utils/media-info.js';
|
} from '../utils/media-info.js';
|
||||||
import { ConstructableLitElement } from './types.js';
|
import { ConstructableLitElement } from './types.js';
|
||||||
|
|
||||||
customElements.whenDefined('ha-web-rtc-player').then(() => {
|
void customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||||
const HaWebRtcPlayer = customElements.get(
|
const HaWebRtcPlayer = customElements.get(
|
||||||
'ha-web-rtc-player',
|
'ha-web-rtc-player',
|
||||||
) as ConstructableLitElement;
|
) as ConstructableLitElement;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transmitting: breathe between a softer and a fuller glow — never fully fades,
|
// Transmitting: breathe between a softer and a fuller glow -- never fully fades,
|
||||||
// so the camera always reads as live.
|
// so the camera always reads as live.
|
||||||
@keyframes transmitting-vignette-pulse {
|
@keyframes transmitting-vignette-pulse {
|
||||||
0%,
|
0%,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Shared "pop" enter/exit animation for overlays.
|
// Shared "pop" enter/exit animation for overlays.
|
||||||
//
|
//
|
||||||
// `@include pop-in` auto-plays an entrance on render. `@include pop-out` —
|
// `@include pop-in` auto-plays an entrance on render. `@include pop-out` --
|
||||||
// typically guarded by an `.exiting` class — plays the matching exit; it is
|
// typically guarded by an `.exiting` class -- plays the matching exit; it is
|
||||||
// named `pop-out` so an `animationend` handler can detect exit completion and
|
// named `pop-out` so an `animationend` handler can detect exit completion and
|
||||||
// unmount the element.
|
// unmount the element.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@ export interface MediaLoadedInfo {
|
|||||||
mediaPlayerController?: MediaPlayerController;
|
mediaPlayerController?: MediaPlayerController;
|
||||||
capabilities?: MediaLoadedCapabilities;
|
capabilities?: MediaLoadedCapabilities;
|
||||||
|
|
||||||
// Universal key identifying "what this media belongs to" — a camera ID for
|
// Universal key identifying "what this media belongs to" -- a camera ID for
|
||||||
// live, a media ID for the viewer, or a sentinel for the image view.
|
// live, a media ID for the viewer, or a sentinel for the image view.
|
||||||
targetID?: string;
|
targetID?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -35,13 +35,13 @@ export const hasAudio = (
|
|||||||
pc?: RTCPeerConnection | null,
|
pc?: RTCPeerConnection | null,
|
||||||
mseCodecs?: string,
|
mseCodecs?: string,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
// For WebRTC: Check if there's an audio receiver with an active track.
|
// For WebRTC: Check if there's an audio receiver with an active track. We
|
||||||
// We check that the track is not muted because muted means no media data
|
// check that the track is not muted because muted means no media data is
|
||||||
// is flowing (e.g., the source isn't producing audio). It is not related to
|
// flowing (e.g., the source isn't producing audio). It is not related to the
|
||||||
// the audio being muted by the user on the receiving end.
|
// audio being muted by the user on the receiving end. Only trust receivers
|
||||||
// Only trust receivers when the connection is actually established — a stale
|
// when the connection is actually established -- a stale RTCPeerConnection
|
||||||
// RTCPeerConnection (e.g. WebRTC failed, fell back to MSE) will have
|
// (e.g. WebRTC failed, fell back to MSE) will have receivers with muted
|
||||||
// receivers with muted tracks that don't reflect actual media availability.
|
// tracks that don't reflect actual media availability.
|
||||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2417
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2417
|
||||||
if (pc && pc.connectionState === 'connected') {
|
if (pc && pc.connectionState === 'connected') {
|
||||||
const receivers = pc.getReceivers();
|
const receivers = pc.getReceivers();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AdvancedCameraCardError } from '../types.js';
|
import { AdvancedCameraCardError } from '../types.js';
|
||||||
|
|
||||||
// Narrows an unknown error to its structured object `context` — non-null and
|
// Narrows an unknown error to its structured object `context` -- non-null and
|
||||||
// of object type — or null if the error is not an AdvancedCameraCardError or
|
// of object type -- or null if the error is not an AdvancedCameraCardError or
|
||||||
// has no usable context. Consolidates the instanceof + typeof + null-guard
|
// has no usable context. Consolidates the instanceof + typeof + null-guard
|
||||||
// dance that notification builders and error handlers would otherwise repeat.
|
// dance that notification builders and error handlers would otherwise repeat.
|
||||||
export const getContextFromError = (error: unknown): object | null =>
|
export const getContextFromError = (error: unknown): object | null =>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Walk up `element`'s ancestor chain (through shadow boundaries) looking for
|
* Walk up `element`'s ancestor chain (through shadow boundaries) looking for
|
||||||
* an ancestor with the given tag name. Returns true if one is found and that
|
* an ancestor with the given tag name. Returns true if one is found and that
|
||||||
* same element also appears in the event's composedPath — indicating the event
|
* same element also appears in the event's composedPath -- indicating the event
|
||||||
* originated from within the same subtree as the element.
|
* originated from within the same subtree as the element.
|
||||||
*/
|
*/
|
||||||
export const isAncestorInEventPath = (
|
export const isAncestorInEventPath = (
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ const MEDIA_INFO_HEIGHT_CUTOFF = 50;
|
|||||||
const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF;
|
const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a MediaLoadedInfo object. `targetID` is intentionally NOT an option
|
* Create a MediaLoadedInfo object. `targetID` is intentionally NOT an option --
|
||||||
* — it's owned by the source controller (`MediaLoadedInfoSourceController`)
|
* it's owned by the source controller (`MediaLoadedInfoSourceController`) and
|
||||||
* and injected at dispatch time, so leaves don't have to (and can't) plumb
|
* injected at dispatch time, so leaves don't have to (and can't) plumb it
|
||||||
* it through info construction.
|
* through info construction.
|
||||||
* @param source An event or HTMLElement that should be used as a source.
|
* @param source An event or HTMLElement that should be used as a source.
|
||||||
* @returns A new info or null if one could not be created.
|
* @returns A new info or null if one could not be created.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// Usage of this function needs to be justified with a comment.
|
// Usage of this function needs to be justified with a comment.
|
||||||
export const sleep = async (seconds: number) => {
|
export const sleep = async (seconds: number) => {
|
||||||
|
// This is the low-level delay primitive callers reach for instead of a raw timer.
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
await new Promise((r) => setTimeout(r, seconds * 1000));
|
await new Promise((r) => setTimeout(r, seconds * 1000));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export class Timer {
|
|||||||
|
|
||||||
public start(seconds: number, func: () => void): void {
|
public start(seconds: number, func: () => void): void {
|
||||||
this.stop();
|
this.stop();
|
||||||
|
// This class is the sanctioned wrapper for the browser timer APIs.
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
this._timer = window.setTimeout(() => {
|
this._timer = window.setTimeout(() => {
|
||||||
this._timer = null;
|
this._timer = null;
|
||||||
func();
|
func();
|
||||||
@@ -28,6 +30,8 @@ export class Timer {
|
|||||||
|
|
||||||
public startRepeated(seconds: number, func: () => void): void {
|
public startRepeated(seconds: number, func: () => void): void {
|
||||||
this.stop();
|
this.stop();
|
||||||
|
// This class is the sanctioned wrapper for the browser timer APIs.
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
this._timer = window.setInterval(() => {
|
this._timer = window.setInterval(() => {
|
||||||
func();
|
func();
|
||||||
}, seconds * 1000);
|
}, seconds * 1000);
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ function strip(schema: z.ZodType, cache: Map<z.ZodType, z.ZodType>): z.ZodType {
|
|||||||
let result: z.ZodType;
|
let result: z.ZodType;
|
||||||
|
|
||||||
if (schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault) {
|
if (schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault) {
|
||||||
// Unwrap the default — don't cache the wrapper itself.
|
// Unwrap the default -- don't cache the wrapper itself.
|
||||||
result = strip(toClassic(schema.unwrap()), cache);
|
result = strip(toClassic(schema.unwrap()), cache);
|
||||||
} else if (schema instanceof z.ZodObject) {
|
} else if (schema instanceof z.ZodObject) {
|
||||||
const newShape: Record<string, z.core.$ZodType> = {};
|
const newShape: Record<string, z.core.$ZodType> = {};
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { View } from './view';
|
|||||||
// the target-ID namespace. Must not collide with real camera IDs or media IDs.
|
// the target-ID namespace. Must not collide with real camera IDs or media IDs.
|
||||||
export const IMAGE_VIEW_TARGET_ID_SENTINEL = '__IMAGE_VIEW__';
|
export const IMAGE_VIEW_TARGET_ID_SENTINEL = '__IMAGE_VIEW__';
|
||||||
|
|
||||||
// Returns a universal target identifier for the current view — the single key
|
// Returns a universal target identifier for the current view -- the single key
|
||||||
// used by PTZ/zoom state and media retry epochs to identify "what is currently
|
// used by PTZ/zoom state and media retry epochs to identify "what is currently
|
||||||
// being displayed." For live, this is the *base* camera ID (substream is an
|
// being displayed." For live, this is the *base* camera ID (substream is an
|
||||||
// implementation detail of how to play camera X, not a separate logical
|
// implementation detail of how to play camera X, not a separate logical
|
||||||
// identity — see `getStreamCameraID` for the substream-aware variant used
|
// identity -- see `getStreamCameraID` for the substream-aware variant used only
|
||||||
// only inside the playback chain).
|
// inside the playback chain).
|
||||||
export const getViewTargetID = (view: View): string | null => {
|
export const getViewTargetID = (view: View): string | null => {
|
||||||
if (view.isViewerView()) {
|
if (view.isViewerView()) {
|
||||||
return view.queryResults?.getSelectedResult()?.getID() ?? null;
|
return view.queryResults?.getSelectedResult()?.getID() ?? null;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ describe('ActionHandler', () => {
|
|||||||
// A document-level mouseup should cancel the hold timer.
|
// A document-level mouseup should cancel the hold timer.
|
||||||
document.dispatchEvent(new MouseEvent('mouseup'));
|
document.dispatchEvent(new MouseEvent('mouseup'));
|
||||||
|
|
||||||
// Advance past hold time — hold should NOT have triggered.
|
// Advance past hold time -- hold should NOT have triggered.
|
||||||
vi.advanceTimersByTime(500);
|
vi.advanceTimersByTime(500);
|
||||||
|
|
||||||
element.dispatchEvent(new MouseEvent('click'));
|
element.dispatchEvent(new MouseEvent('click'));
|
||||||
|
|||||||
@@ -501,7 +501,7 @@ describe('should handle ptz action', () => {
|
|||||||
|
|
||||||
// Emulate the stop being called while the action is running, but before
|
// Emulate the stop being called while the action is running, but before
|
||||||
// the *next* timer is scheduled.
|
// the *next* timer is scheduled.
|
||||||
let resolve: () => void;
|
let resolve: () => void = () => {};
|
||||||
const promise: Promise<void> = new Promise((_resolve) => {
|
const promise: Promise<void> = new Promise((_resolve) => {
|
||||||
resolve = _resolve;
|
resolve = _resolve;
|
||||||
});
|
});
|
||||||
@@ -512,7 +512,7 @@ describe('should handle ptz action', () => {
|
|||||||
|
|
||||||
action.stop();
|
action.stop();
|
||||||
|
|
||||||
resolve!();
|
resolve();
|
||||||
await vi.runOnlyPendingTimersAsync();
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
|
||||||
// There should be no additional calls.
|
// There should be no additional calls.
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ interface AudioMocks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Uses real jsdom HTMLAudioElement instances and only stubs the parts jsdom
|
// Uses real jsdom HTMLAudioElement instances and only stubs the parts jsdom
|
||||||
// can't fulfil (`play()` / `pause()` — no audio backend). Tests then exercise
|
// can't fulfil (`play()` / `pause()` -- no audio backend). Tests then exercise
|
||||||
// observable behaviour: registered listeners fire via `dispatchEvent`,
|
// observable behaviour: registered listeners fire via `dispatchEvent`, property
|
||||||
// property writes round-trip on the element, etc.
|
// writes round-trip on the element, etc.
|
||||||
//
|
//
|
||||||
// Called once at module load. The `beforeEach`/`afterEach` calls inside this
|
// Called once at module load. The `beforeEach`/`afterEach` calls inside this
|
||||||
// helper register Vitest hooks at the file level — Vitest picks them up just
|
// helper register Vitest hooks at the file level -- Vitest picks them up just
|
||||||
// as if they had been written at the top of the file — so every test in the
|
// as if they had been written at the top of the file -- so every test in the
|
||||||
// file gets fresh mocks installed/torn down automatically.
|
// file gets fresh mocks installed/torn down automatically.
|
||||||
const useAudioElementMocks = (): AudioMocks => {
|
const useAudioElementMocks = (): AudioMocks => {
|
||||||
const handle = { instances: [] as HTMLAudioElement[] } as AudioMocks;
|
const handle = { instances: [] as HTMLAudioElement[] } as AudioMocks;
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { mock, MockProxy } from 'vitest-mock-extended';
|
|||||||
// constructs an AudioContext directly; here we stub the global `AudioContext`
|
// constructs an AudioContext directly; here we stub the global `AudioContext`
|
||||||
// to return a deep-mocked instance whose `createOscillator()` / `createGain()`
|
// to return a deep-mocked instance whose `createOscillator()` / `createGain()`
|
||||||
// factories return a *fresh* mock per call (not a shared one). This lets tests
|
// factories return a *fresh* mock per call (not a shared one). This lets tests
|
||||||
// assert against individual notes — e.g. `audio.oscillators[2].frequency.value`
|
// assert against individual notes -- e.g.
|
||||||
// — instead of having every call write over the same observable state.
|
// `audio.oscillators[2].frequency.value` -- instead of having every call write
|
||||||
|
// over the same observable state.
|
||||||
interface AudioMocks {
|
interface AudioMocks {
|
||||||
audioContext: MockProxy<AudioContext>;
|
audioContext: MockProxy<AudioContext>;
|
||||||
audioContextCtor: Mock<[], MockProxy<AudioContext>>;
|
audioContextCtor: Mock<[], MockProxy<AudioContext>>;
|
||||||
@@ -15,7 +16,7 @@ interface AudioMocks {
|
|||||||
oscillators: MockProxy<OscillatorNode>[];
|
oscillators: MockProxy<OscillatorNode>[];
|
||||||
gains: MockProxy<GainNode>[];
|
gains: MockProxy<GainNode>[];
|
||||||
|
|
||||||
// gainParams[i] is the AudioParam exposed by `gains[i].gain` — kept as a
|
// gainParams[i] is the AudioParam exposed by `gains[i].gain` -- kept as a
|
||||||
// parallel array because `mock<GainNode>()` doesn't auto-populate the
|
// parallel array because `mock<GainNode>()` doesn't auto-populate the
|
||||||
// AudioParam interface as a callable deep mock (we wire it up by hand).
|
// AudioParam interface as a callable deep mock (we wire it up by hand).
|
||||||
gainParams: MockProxy<AudioParam>[];
|
gainParams: MockProxy<AudioParam>[];
|
||||||
@@ -26,9 +27,9 @@ interface AudioMocks {
|
|||||||
// can read its fields after each `beforeEach` runs.
|
// can read its fields after each `beforeEach` runs.
|
||||||
//
|
//
|
||||||
// Called once at module load. The `beforeEach`/`afterEach` calls inside this
|
// Called once at module load. The `beforeEach`/`afterEach` calls inside this
|
||||||
// helper register Vitest hooks at the file level — Vitest picks them up just as
|
// helper register Vitest hooks at the file level -- Vitest picks them up just
|
||||||
// if they had been written at the top of the file — so every test in the file
|
// as if they had been written at the top of the file -- so every test in the
|
||||||
// gets fresh mocks installed/torn down automatically.
|
// file gets fresh mocks installed/torn down automatically.
|
||||||
export const useAudioMocks = (): AudioMocks => {
|
export const useAudioMocks = (): AudioMocks => {
|
||||||
const audio = {} as AudioMocks;
|
const audio = {} as AudioMocks;
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ export const useAudioMocks = (): AudioMocks => {
|
|||||||
|
|
||||||
// The source chains `.catch(...)` on the close() Promise.
|
// The source chains `.catch(...)` on the close() Promise.
|
||||||
vi.mocked(audio.audioContext.close).mockResolvedValue();
|
vi.mocked(audio.audioContext.close).mockResolvedValue();
|
||||||
// The base class reads `_currentTime` from this — left as a deep-mock spy
|
// The base class reads `_currentTime` from this -- left as a deep-mock spy
|
||||||
// by default it'd return a function, so anchor it at 0 for predictable
|
// by default it'd return a function, so anchor it at 0 for predictable
|
||||||
// scheduling assertions.
|
// scheduling assertions.
|
||||||
Object.defineProperty(audio.audioContext, 'currentTime', {
|
Object.defineProperty(audio.audioContext, 'currentTime', {
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ describe('OverridesManager', () => {
|
|||||||
it('with `invalid_type` surfacing the attempted value', () => {
|
it('with `invalid_type` surfacing the attempted value', () => {
|
||||||
const error = runInvalidOverride((config) => {
|
const error = runInvalidOverride((config) => {
|
||||||
assert(config.overrides);
|
assert(config.overrides);
|
||||||
// @ts-expect-error — intentionally invalid runtime value to trigger
|
// @ts-expect-error -- intentionally invalid runtime value to trigger
|
||||||
// Zod's `invalid_type` issue code.
|
// Zod's `invalid_type` issue code.
|
||||||
config.overrides[0].merge = 6;
|
config.overrides[0].merge = 6;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import screenfull from 'screenfull';
|
import screenfull from 'screenfull';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { ScreenfullFullScreenProvider } from '../../../../src/card-controller/fullscreen/screenfull';
|
import { ScreenfullFullScreenProvider } from '../../../../src/card-controller/fullscreen/screenfull';
|
||||||
import { createCardAPI, setScreenfulEnabled } from '../../../test-utils';
|
import { createCardAPI, flushPromises, setScreenfulEnabled } from '../../../test-utils';
|
||||||
|
|
||||||
vi.mock('screenfull', () => ({
|
vi.mock('screenfull', () => ({
|
||||||
default: {
|
default: {
|
||||||
@@ -21,6 +21,11 @@ const setScreenfulFullscreen = (fullscreen: boolean): void => {
|
|||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
describe('ScreenfullFullScreenProvider', () => {
|
describe('ScreenfullFullScreenProvider', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(screenfull.request).mockResolvedValue();
|
||||||
|
vi.mocked(screenfull.exit).mockResolvedValue();
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
@@ -158,5 +163,30 @@ describe('ScreenfullFullScreenProvider', () => {
|
|||||||
expect(screenfull.request).not.toBeCalled();
|
expect(screenfull.request).not.toBeCalled();
|
||||||
expect(screenfull.exit).not.toBeCalled();
|
expect(screenfull.exit).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should swallow a rejected fullscreen request', async () => {
|
||||||
|
setScreenfulEnabled(true);
|
||||||
|
const api = createCardAPI();
|
||||||
|
const element = document.createElement('div');
|
||||||
|
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||||
|
vi.mocked(screenfull.request).mockRejectedValue(new Error('denied'));
|
||||||
|
const provider = new ScreenfullFullScreenProvider(api, vi.fn());
|
||||||
|
|
||||||
|
provider.setFullscreen(true);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(screenfull.request).toBeCalledWith(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should swallow a rejected fullscreen exit', async () => {
|
||||||
|
setScreenfulEnabled(true);
|
||||||
|
vi.mocked(screenfull.exit).mockRejectedValue(new Error('not in fullscreen'));
|
||||||
|
const provider = new ScreenfullFullScreenProvider(createCardAPI(), vi.fn());
|
||||||
|
|
||||||
|
provider.setFullscreen(false);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(screenfull.exit).toBeCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { mock } from 'vitest-mock-extended';
|
|||||||
import { WebkitFullScreenProvider } from '../../../../src/card-controller/fullscreen/webkit';
|
import { WebkitFullScreenProvider } from '../../../../src/card-controller/fullscreen/webkit';
|
||||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||||
import { MediaPlayerController, WebkitHTMLVideoElement } from '../../../../src/types';
|
import { MediaPlayerController, WebkitHTMLVideoElement } from '../../../../src/types';
|
||||||
import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils';
|
import {
|
||||||
|
createCardAPI,
|
||||||
|
createMediaLoadedInfo,
|
||||||
|
flushPromises,
|
||||||
|
} from '../../../test-utils';
|
||||||
|
|
||||||
const createWebkitVideoElement = (): HTMLVideoElement &
|
const createWebkitVideoElement = (): HTMLVideoElement &
|
||||||
Partial<WebkitHTMLVideoElement> => {
|
Partial<WebkitHTMLVideoElement> => {
|
||||||
@@ -242,7 +246,7 @@ describe('WebkitFullScreenProvider', () => {
|
|||||||
provider.connect();
|
provider.connect();
|
||||||
|
|
||||||
const element = createWebkitVideoElement();
|
const element = createWebkitVideoElement();
|
||||||
element.play = vi.fn();
|
element.play = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
const mediaPlayerController = createMediaPlayerController(element);
|
const mediaPlayerController = createMediaPlayerController(element);
|
||||||
vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue(
|
vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue(
|
||||||
@@ -263,4 +267,30 @@ describe('WebkitFullScreenProvider', () => {
|
|||||||
|
|
||||||
expect(element.play).toBeCalled();
|
expect(element.play).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should swallow a rejected video replay after fullscreen ends', async () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const stateManager = new ConditionStateManager();
|
||||||
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||||
|
|
||||||
|
const provider = new WebkitFullScreenProvider(api, vi.fn());
|
||||||
|
provider.connect();
|
||||||
|
|
||||||
|
const element = createWebkitVideoElement();
|
||||||
|
element.play = vi.fn().mockRejectedValue(new Error('denied'));
|
||||||
|
|
||||||
|
const mediaPlayerController = createMediaPlayerController(element);
|
||||||
|
vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue(
|
||||||
|
createMediaLoadedInfo({ mediaPlayerController }),
|
||||||
|
);
|
||||||
|
stateManager.setState({
|
||||||
|
mediaLoadedInfo: createMediaLoadedInfo({ mediaPlayerController }),
|
||||||
|
});
|
||||||
|
|
||||||
|
element.dispatchEvent(new Event('webkitendfullscreen'));
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(element.play).toBeCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ describe('HASSManager', () => {
|
|||||||
startingHASS.config.state = STATE_STARTING;
|
startingHASS.config.state = STATE_STARTING;
|
||||||
manager.setHASS(startingHASS);
|
manager.setHASS(startingHASS);
|
||||||
|
|
||||||
// No reinit yet — HA isn't fully ready.
|
// No reinit yet -- HA isn't fully ready.
|
||||||
expect(api.getInitializationManager().uninitialize).not.toBeCalled();
|
expect(api.getInitializationManager().uninitialize).not.toBeCalled();
|
||||||
expect(api.getCameraManager().destroy).not.toBeCalled();
|
expect(api.getCameraManager().destroy).not.toBeCalled();
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ describe('HASSManager', () => {
|
|||||||
startingHASS.config.state = STATE_STARTING;
|
startingHASS.config.state = STATE_STARTING;
|
||||||
manager.setHASS(startingHASS);
|
manager.setHASS(startingHASS);
|
||||||
|
|
||||||
// WS came back but integrations still loading — wait for RUNNING.
|
// WS came back but integrations still loading -- wait for RUNNING.
|
||||||
expect(api.getInitializationManager().uninitialize).not.toBeCalled();
|
expect(api.getInitializationManager().uninitialize).not.toBeCalled();
|
||||||
expect(api.getCameraManager().destroy).not.toBeCalled();
|
expect(api.getCameraManager().destroy).not.toBeCalled();
|
||||||
});
|
});
|
||||||
@@ -190,9 +190,9 @@ describe('HASSManager', () => {
|
|||||||
readyHASS.config.state = STATE_RUNNING;
|
readyHASS.config.state = STATE_RUNNING;
|
||||||
manager.setHASS(readyHASS);
|
manager.setHASS(readyHASS);
|
||||||
|
|
||||||
// First-ever hass set — there's no "previous not-ready state" to
|
// First-ever hass set -- there's no "previous not-ready state" to
|
||||||
// transition from, so the normal first-load init flow applies and we
|
// transition from, so the normal first-load init flow applies and we must
|
||||||
// must not blow away cameras.
|
// not blow away cameras.
|
||||||
expect(api.getInitializationManager().uninitialize).not.toBeCalled();
|
expect(api.getInitializationManager().uninitialize).not.toBeCalled();
|
||||||
expect(api.getCameraManager().destroy).not.toBeCalled();
|
expect(api.getCameraManager().destroy).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -198,11 +198,11 @@ describe('IssueManager', () => {
|
|||||||
|
|
||||||
const manager = new IssueManager(api);
|
const manager = new IssueManager(api);
|
||||||
|
|
||||||
// hasIssue returns true from the start — simulates trigger() having
|
// hasIssue returns true from the start -- simulates trigger() having
|
||||||
// already mutated state before detectDynamic snapshots. The
|
// already mutated state before detectDynamic snapshots. The before/after
|
||||||
// before/after check inside detectDynamic sees true→true (no
|
// check inside detectDynamic sees true→true (no transition), but the
|
||||||
// transition), but the presence comparison against ConditionState
|
// presence comparison against ConditionState must still detect the
|
||||||
// must still detect the change.
|
// change.
|
||||||
const description = createIssueDescription();
|
const description = createIssueDescription();
|
||||||
const issue = createIssue('config_error', {
|
const issue = createIssue('config_error', {
|
||||||
hasIssue: vi.fn().mockReturnValue(true),
|
hasIssue: vi.fn().mockReturnValue(true),
|
||||||
@@ -219,7 +219,7 @@ describe('IssueManager', () => {
|
|||||||
expect(api.getCardElementManager().update).toBeCalled();
|
expect(api.getCardElementManager().update).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should never auto-popup on trigger — non-full-card issues surface via the status-bar icon; user clicks to open', () => {
|
it('should never auto-popup on trigger -- non-full-card issues surface via the status-bar icon; user clicks to open', () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
|
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
|
||||||
|
|
||||||
@@ -250,8 +250,8 @@ describe('IssueManager', () => {
|
|||||||
|
|
||||||
expect(issue.retry).toBeCalled();
|
expect(issue.retry).toBeCalled();
|
||||||
|
|
||||||
// Timer should have been reset — advancing less than retrySeconds
|
// Timer should have been reset -- advancing less than retrySeconds should
|
||||||
// should not fire it again.
|
// not fire it again.
|
||||||
assert(issue.retry);
|
assert(issue.retry);
|
||||||
vi.mocked(issue.retry).mockClear();
|
vi.mocked(issue.retry).mockClear();
|
||||||
vi.advanceTimersByTime(500);
|
vi.advanceTimersByTime(500);
|
||||||
@@ -312,11 +312,11 @@ describe('IssueManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call update when an active issue swaps sub-states without changing the key set', () => {
|
it('should call update when an active issue swaps sub-states without changing the key set', () => {
|
||||||
// Simulates ConnectionIssue going from 'lost' to 'starting': the
|
// Simulates ConnectionIssue going from 'lost' to 'starting': the presence
|
||||||
// presence key set ({connection}) is identical, but the description
|
// key set ({connection}) is identical, but the description value differs.
|
||||||
// value differs. Because IssuePresence is a Map<key, description>,
|
// Because IssuePresence is a Map<key, description>, the condition state
|
||||||
// the condition state diff sees the value-level change and fires
|
// diff sees the value-level change and fires listeners -- the
|
||||||
// listeners — the IssueManager's own listener calls update().
|
// IssueManager's own listener calls update().
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
|
|
||||||
// Real ConditionStateManager so its isEqual-based diff actually runs.
|
// Real ConditionStateManager so its isEqual-based diff actually runs.
|
||||||
@@ -638,7 +638,7 @@ describe('IssueManager', () => {
|
|||||||
const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' });
|
const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' });
|
||||||
manager.evaluate();
|
manager.evaluate();
|
||||||
|
|
||||||
// Run two retries — second delay should be 2x the first.
|
// Run two retries -- second delay should be 2x the first.
|
||||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
|
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
|
||||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 0.75 * 1000);
|
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 0.75 * 1000);
|
||||||
expect(issue.retry).toBeCalledTimes(2);
|
expect(issue.retry).toBeCalledTimes(2);
|
||||||
@@ -671,16 +671,16 @@ describe('IssueManager', () => {
|
|||||||
});
|
});
|
||||||
manager.evaluate();
|
manager.evaluate();
|
||||||
|
|
||||||
// Three gated firings — each at the base delay (22.5s with 0.75 jitter).
|
// Three gated firings -- each at the base delay (22.5s with 0.75 jitter).
|
||||||
// If the counter were incrementing on gated fires, the second would be
|
// If the counter were incrementing on gated fires, the second would be at
|
||||||
// at 45s and we'd never reach it after only 22.5s.
|
// 45s and we'd never reach it after only 22.5s.
|
||||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
|
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
|
||||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
|
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
|
||||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
|
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
|
||||||
expect(issue.retry).not.toBeCalled();
|
expect(issue.retry).not.toBeCalled();
|
||||||
|
|
||||||
// Clear the interaction. The next firing — still at the base delay —
|
// Clear the interaction. The next firing -- still at the base delay -- is
|
||||||
// is now allowed and the retry runs.
|
// now allowed and the retry runs.
|
||||||
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
|
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
|
||||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
|
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
|
||||||
expect(issue.retry).toBeCalledTimes(1);
|
expect(issue.retry).toBeCalledTimes(1);
|
||||||
@@ -854,7 +854,7 @@ describe('IssueManager', () => {
|
|||||||
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
|
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
|
||||||
|
|
||||||
const manager = new IssueManager(api);
|
const manager = new IssueManager(api);
|
||||||
// Plain Issue implementation — no optional methods installed.
|
// Plain Issue implementation -- no optional methods installed.
|
||||||
const issue: Issue = {
|
const issue: Issue = {
|
||||||
key: 'config_error',
|
key: 'config_error',
|
||||||
hasIssue: () => false,
|
hasIssue: () => false,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
import { MediaLoadIssue } from '../../../../src/card-controller/issues/issues/media-load';
|
import { MediaLoadIssue } from '../../../../src/card-controller/issues/issues/media-load';
|
||||||
import { InternalCallbackActionConfig } from '../../../../src/config/schema/actions/custom/internal';
|
import { InternalCallbackActionConfig } from '../../../../src/config/schema/actions/custom/internal';
|
||||||
|
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../../src/view/target-id';
|
||||||
import { View } from '../../../../src/view/view';
|
import { View } from '../../../../src/view/view';
|
||||||
import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils';
|
import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils';
|
||||||
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../../src/view/target-id';
|
|
||||||
|
|
||||||
const createAPI = () => createCardAPI();
|
const createAPI = () => createCardAPI();
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
vi.advanceTimersByTime(10000);
|
vi.advanceTimersByTime(10000);
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
|
||||||
// Same target, different media view — issue stays active.
|
// Same target, different media view -- issue stays active.
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'clip' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'clip' });
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -145,7 +145,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
vi.advanceTimersByTime(10000);
|
vi.advanceTimersByTime(10000);
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
|
||||||
// Switch to camera-2 which has no error — should deactivate and start
|
// Switch to camera-2 which has no error -- should deactivate and start
|
||||||
// a fresh timer for the new target.
|
// a fresh timer for the new target.
|
||||||
issue.detectDynamic({ targetID: 'camera-2', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-2', view: 'live' });
|
||||||
expect(issue.hasIssue()).toBe(false);
|
expect(issue.hasIssue()).toBe(false);
|
||||||
@@ -164,7 +164,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
|
||||||
// Switch to camera-2 which also has an error — should stay active.
|
// Switch to camera-2 which also has an error -- should stay active.
|
||||||
issue.detectDynamic({ targetID: 'camera-2', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-2', view: 'live' });
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -456,7 +456,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
|
|
||||||
issue.retry();
|
issue.retry();
|
||||||
|
|
||||||
// Issue remains active — no new 10s grace period. The error stays
|
// Issue remains active -- no new 10s grace period. The error stays
|
||||||
// visible while the provider re-attempts loading underneath.
|
// visible while the provider re-attempts loading underneath.
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -542,9 +542,9 @@ describe('MediaLoadIssue', () => {
|
|||||||
|
|
||||||
issue.retry();
|
issue.retry();
|
||||||
|
|
||||||
// After retry, the issue stays active and the errored target is
|
// After retry, the issue stays active and the errored target is preserved
|
||||||
// preserved — no new 10s grace period. If media:loaded fires, the
|
// -- no new 10s grace period. If media:loaded fires, the existing
|
||||||
// existing _handleMediaLoaded path will clear everything.
|
// _handleMediaLoaded path will clear everything.
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
@@ -579,8 +579,8 @@ describe('MediaLoadIssue', () => {
|
|||||||
// Card detaches: timer must stop.
|
// Card detaches: timer must stop.
|
||||||
issue.suspend();
|
issue.suspend();
|
||||||
|
|
||||||
// Full 10s later (plus margin) the timer has NOT matured — the user
|
// Full 10s later (plus margin) the timer has NOT matured -- the user was
|
||||||
// was offscreen and that time does not count against them.
|
// offscreen and that time does not count against them.
|
||||||
vi.advanceTimersByTime(20000);
|
vi.advanceTimersByTime(20000);
|
||||||
expect(issue.hasIssue()).toBe(false);
|
expect(issue.hasIssue()).toBe(false);
|
||||||
expect(onChange).not.toBeCalled();
|
expect(onChange).not.toBeCalled();
|
||||||
@@ -594,7 +594,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
vi.advanceTimersByTime(10000);
|
vi.advanceTimersByTime(10000);
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
|
||||||
// Card detaches — issue must remain visible on reattach.
|
// Card detaches -- issue must remain visible on reattach.
|
||||||
issue.suspend();
|
issue.suspend();
|
||||||
|
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
@@ -609,8 +609,8 @@ describe('MediaLoadIssue', () => {
|
|||||||
issue.suspend();
|
issue.suspend();
|
||||||
|
|
||||||
// Reattach: the manager's resume() triggers evaluate() → detectDynamic.
|
// Reattach: the manager's resume() triggers evaluate() → detectDynamic.
|
||||||
// The target is still loading, so the timer arms with a fresh 10s
|
// The target is still loading, so the timer arms with a fresh 10s window
|
||||||
// window — not whatever was left when we suspended.
|
// -- not whatever was left when we suspended.
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
|
|
||||||
vi.advanceTimersByTime(9999);
|
vi.advanceTimersByTime(9999);
|
||||||
|
|||||||
@@ -68,6 +68,28 @@ describe('IssueStateManager', () => {
|
|||||||
expect(mockLegacyResource.detectStatic).toBeCalledWith(hass);
|
expect(mockLegacyResource.detectStatic).toBeCalledWith(hass);
|
||||||
expect(mockMediaLoad.detectStatic).toBeCalledWith(hass);
|
expect(mockMediaLoad.detectStatic).toBeCalledWith(hass);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should isolate a failing issue and continue detecting the rest', async () => {
|
||||||
|
const spy = vi.spyOn(console, 'warn').mockReturnValue();
|
||||||
|
assert(mockConfigUpgrade.detectStatic);
|
||||||
|
assert(mockLegacyResource.detectStatic);
|
||||||
|
assert(mockMediaLoad.detectStatic);
|
||||||
|
|
||||||
|
vi.mocked(mockConfigUpgrade.detectStatic).mockRejectedValue(new Error('boom'));
|
||||||
|
|
||||||
|
// A second failure, a non-Error rejection, is isolated and logged too.
|
||||||
|
vi.mocked(mockLegacyResource.detectStatic).mockRejectedValue('bad');
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
const hass = createHASS();
|
||||||
|
|
||||||
|
await expect(manager.detectStatic(hass)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// Detection continued to the final issue despite the two earlier failures.
|
||||||
|
expect(mockMediaLoad.detectStatic).toBeCalledWith(hass);
|
||||||
|
expect(spy).toBeCalledTimes(2);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('trigger', () => {
|
describe('trigger', () => {
|
||||||
|
|||||||
@@ -451,9 +451,9 @@ describe('MediaPlayerManager', () => {
|
|||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
|
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
|
||||||
|
|
||||||
// Bypass schema validation — the code has a runtime guard (TypeScript
|
// Bypass schema validation -- the code has a runtime guard
|
||||||
// narrowing) for the case where dashboard config is present but
|
// (TypeScript narrowing) for the case where dashboard config is
|
||||||
// dashboard_path / view_path are missing.
|
// present but dashboard_path / view_path are missing.
|
||||||
const configWithNoDashboardPaths = createCameraConfig({
|
const configWithNoDashboardPaths = createCameraConfig({
|
||||||
camera_entity: 'camera.foo',
|
camera_entity: 'camera.foo',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -509,7 +509,7 @@ describe('should handle exceptions', () => {
|
|||||||
expect.objectContaining({ error }),
|
expect.objectContaining({ error }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The loading flag must be cleared on error — otherwise gallery/viewer
|
// The loading flag must be cleared on error -- otherwise gallery/viewer
|
||||||
// components render "Awaiting media" indefinitely on top of the error
|
// components render "Awaiting media" indefinitely on top of the error
|
||||||
// notification.
|
// notification.
|
||||||
expect(manager.getView()?.context?.loading?.query).toBeUndefined();
|
expect(manager.getView()?.context?.loading?.query).toBeUndefined();
|
||||||
@@ -538,9 +538,9 @@ describe('should handle exceptions', () => {
|
|||||||
viewQueryExecutor: viewQueryExecutor,
|
viewQueryExecutor: viewQueryExecutor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Concurrent reset during the await — clears `_view` before the
|
// Concurrent reset during the await -- clears `_view` before the rejection
|
||||||
// rejection is processed. The error path must not crash on the null
|
// is processed. The error path must not crash on the null view when
|
||||||
// view when attempting to clear the loading flag.
|
// attempting to clear the loading flag.
|
||||||
viewQueryExecutor.getNewQueryModifiers.mockImplementation(async () => {
|
viewQueryExecutor.getNewQueryModifiers.mockImplementation(async () => {
|
||||||
manager.reset();
|
manager.reset();
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -56,6 +56,19 @@ describe('MicrophoneActionsController', () => {
|
|||||||
expect(microphoneManager.mute).not.toBeCalled();
|
expect(microphoneManager.mute).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should swallow a rejected auto-unmute so a denied microphone does not surface', async () => {
|
||||||
|
const microphoneManager = createMicrophoneManager();
|
||||||
|
vi.mocked(microphoneManager.unmute).mockRejectedValue(new Error('denied'));
|
||||||
|
const controller = new MicrophoneActionsController();
|
||||||
|
controller.setOptions({
|
||||||
|
microphoneManager,
|
||||||
|
autoUnmuteConditions: ['selected' as const],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(controller.setSelectedCamera('camera-1')).resolves.toBeUndefined();
|
||||||
|
expect(microphoneManager.unmute).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('should mute on unselected when transitioning to a new camera', async () => {
|
it('should mute on unselected when transitioning to a new camera', async () => {
|
||||||
const microphoneManager = createMicrophoneManager();
|
const microphoneManager = createMicrophoneManager();
|
||||||
const controller = new MicrophoneActionsController();
|
const controller = new MicrophoneActionsController();
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe('MediaLoadedInfoSinkController', () => {
|
|||||||
// Both cached, but only the selected one is exposed.
|
// Both cached, but only the selected one is exposed.
|
||||||
expect(controller.get()).toBe(infoA);
|
expect(controller.get()).toBe(infoA);
|
||||||
|
|
||||||
// Selecting the other target switches what `get()` returns — without a
|
// Selecting the other target switches what `get()` returns -- without a
|
||||||
// new event arriving for it.
|
// new event arriving for it.
|
||||||
selected = 'target-B';
|
selected = 'target-B';
|
||||||
controller.hostUpdated();
|
controller.hostUpdated();
|
||||||
@@ -102,7 +102,7 @@ describe('MediaLoadedInfoSinkController', () => {
|
|||||||
controller.hostConnected();
|
controller.hostConnected();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Load for an unselected target — cached but inactive.
|
// Load for an unselected target -- cached but inactive.
|
||||||
host.dispatchEvent(
|
host.dispatchEvent(
|
||||||
createMediaLoadedInfoEvent({
|
createMediaLoadedInfoEvent({
|
||||||
info: createMediaLoadedInfo({ targetID: 'target-B' }),
|
info: createMediaLoadedInfo({ targetID: 'target-B' }),
|
||||||
@@ -162,7 +162,7 @@ describe('MediaLoadedInfoSinkController', () => {
|
|||||||
controller.hostUpdated();
|
controller.hostUpdated();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Switch to another target with no cached info — active stays null.
|
// Switch to another target with no cached info -- active stays null.
|
||||||
selected = 'target-B';
|
selected = 'target-B';
|
||||||
controller.hostUpdated();
|
controller.hostUpdated();
|
||||||
|
|
||||||
|
|||||||
@@ -126,9 +126,9 @@ describe('MediaLoadedInfoSourceController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should abort the prior dispatch when targetID changes between calls', () => {
|
it('should abort the prior dispatch when targetID changes between calls', () => {
|
||||||
// Without this, the manager would zombie an entry under the old
|
// Without this, the manager would zombie an entry under the old targetID
|
||||||
// targetID — its `onAbort` cleanup never fires because we never aborted
|
// -- its `onAbort` cleanup never fires because we never aborted the prior
|
||||||
// the prior signal before overwriting `_abort`.
|
// signal before overwriting `_abort`.
|
||||||
let targetID: string | null = 'target-1';
|
let targetID: string | null = 'target-1';
|
||||||
const host = createLitElement();
|
const host = createLitElement();
|
||||||
const controller = new MediaLoadedInfoSourceController(host, {
|
const controller = new MediaLoadedInfoSourceController(host, {
|
||||||
@@ -164,7 +164,7 @@ describe('MediaLoadedInfoSourceController', () => {
|
|||||||
controller.set(createMediaLoadedInfo());
|
controller.set(createMediaLoadedInfo());
|
||||||
const firstSignal = (handler.mock.calls[0][0] as CustomEvent).detail.signal;
|
const firstSignal = (handler.mock.calls[0][0] as CustomEvent).detail.signal;
|
||||||
|
|
||||||
// Disconnect and reconnect — without a fresh `set`.
|
// Disconnect and reconnect -- without a fresh `set`.
|
||||||
controller.hostDisconnected();
|
controller.hostDisconnected();
|
||||||
controller.hostConnected();
|
controller.hostConnected();
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ describe('MediaLoadedInfoSourceController', () => {
|
|||||||
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||||
|
|
||||||
controller.set(createMediaLoadedInfo());
|
controller.set(createMediaLoadedInfo());
|
||||||
// Active registration, no disconnect — connect should be a no-op.
|
// Active registration, no disconnect -- connect should be a no-op.
|
||||||
controller.hostConnected();
|
controller.hostConnected();
|
||||||
|
|
||||||
expect(handler).toBeCalledTimes(1);
|
expect(handler).toBeCalledTimes(1);
|
||||||
@@ -228,7 +228,7 @@ describe('MediaLoadedInfoSourceController', () => {
|
|||||||
targetID = 'target-2';
|
targetID = 'target-2';
|
||||||
controller.hostConnected();
|
controller.hostConnected();
|
||||||
|
|
||||||
// No re-dispatch — the stale cache was discarded.
|
// No re-dispatch -- the stale cache was discarded.
|
||||||
expect(handler).toBeCalledTimes(1);
|
expect(handler).toBeCalledTimes(1);
|
||||||
|
|
||||||
// A subsequent set() under the new target dispatches fresh.
|
// A subsequent set() under the new target dispatches fresh.
|
||||||
|
|||||||
@@ -771,7 +771,7 @@ describe('SignedURLController', () => {
|
|||||||
controller.hostUpdate();
|
controller.hostUpdate();
|
||||||
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Resolve the original request — should still succeed.
|
// Resolve the original request -- should still succeed.
|
||||||
resolveProxy?.({ endpoint: 'http://proxied-url.com', sign: false });
|
resolveProxy?.({ endpoint: 'http://proxied-url.com', sign: false });
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
@@ -809,7 +809,7 @@ describe('SignedURLController', () => {
|
|||||||
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
||||||
expect(controller.getValue()).toBe('http://signed.com');
|
expect(controller.getValue()).toBe('http://signed.com');
|
||||||
|
|
||||||
// Change only the extraneous field — should hit the cache, not re-fetch.
|
// Change only the extraneous field -- should hit the cache, not re-fetch.
|
||||||
extraneous = false;
|
extraneous = false;
|
||||||
await controller.hostUpdate();
|
await controller.hostUpdate();
|
||||||
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -314,12 +314,12 @@ describe('StatusBarController', () => {
|
|||||||
sufficient: true,
|
sufficient: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start with permanent item — bar stays visible.
|
// Start with permanent item -- bar stays visible.
|
||||||
controller.setItems([permanentItem, nonPermanentItem]);
|
controller.setItems([permanentItem, nonPermanentItem]);
|
||||||
vi.advanceTimersByTime(10000);
|
vi.advanceTimersByTime(10000);
|
||||||
expect(host.getAttribute('hide')).toBe(null);
|
expect(host.getAttribute('hide')).toBe(null);
|
||||||
|
|
||||||
// Remove permanent item — popup timer starts.
|
// Remove permanent item -- popup timer starts.
|
||||||
controller.setItems([nonPermanentItem]);
|
controller.setItems([nonPermanentItem]);
|
||||||
expect(host.getAttribute('hide')).toBe(null);
|
expect(host.getAttribute('hide')).toBe(null);
|
||||||
|
|
||||||
@@ -342,7 +342,7 @@ describe('StatusBarController', () => {
|
|||||||
string: 'Title',
|
string: 'Title',
|
||||||
sufficient: true,
|
sufficient: true,
|
||||||
};
|
};
|
||||||
// A permanent item that is NOT sufficient — removing it does not
|
// A permanent item that is NOT sufficient -- removing it does not
|
||||||
// change the sufficient-values set, so the popup timer takes the
|
// change the sufficient-values set, so the popup timer takes the
|
||||||
// dedicated permanent-removal branch.
|
// dedicated permanent-removal branch.
|
||||||
const permanentInsufficientItem = {
|
const permanentInsufficientItem = {
|
||||||
@@ -356,7 +356,7 @@ describe('StatusBarController', () => {
|
|||||||
vi.advanceTimersByTime(10000);
|
vi.advanceTimersByTime(10000);
|
||||||
expect(host.getAttribute('hide')).toBe(null);
|
expect(host.getAttribute('hide')).toBe(null);
|
||||||
|
|
||||||
// Remove the permanent (insufficient) item — sufficient values are
|
// Remove the permanent (insufficient) item -- sufficient values are
|
||||||
// unchanged, but the popup timer must still start.
|
// unchanged, but the popup timer must still start.
|
||||||
controller.setItems([sufficientItem]);
|
controller.setItems([sufficientItem]);
|
||||||
expect(host.getAttribute('hide')).toBe(null);
|
expect(host.getAttribute('hide')).toBe(null);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { assert, describe, expect, it } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
import { CameraManager } from '../../../../src/camera-manager/manager';
|
import { CameraManager } from '../../../../src/camera-manager/manager';
|
||||||
import { ThumbnailFeatureController } from '../../../../src/components-lib/thumbnail/feature/controller';
|
import { ThumbnailFeatureController } from '../../../../src/components-lib/thumbnail/feature/controller';
|
||||||
@@ -12,6 +12,8 @@ describe('ThumbnailFeatureController', () => {
|
|||||||
title: 'Test Event',
|
title: 'Test Event',
|
||||||
cameraID: 'camera_1',
|
cameraID: 'camera_1',
|
||||||
});
|
});
|
||||||
|
const itemStartTime = itemWithTime.getStartTime();
|
||||||
|
assert(itemStartTime);
|
||||||
|
|
||||||
describe('should set title', () => {
|
describe('should set title', () => {
|
||||||
it('should set title with start time ', () => {
|
it('should set title with start time ', () => {
|
||||||
@@ -20,7 +22,7 @@ describe('ThumbnailFeatureController', () => {
|
|||||||
controller.calculate(null, itemWithTime, false);
|
controller.calculate(null, itemWithTime, false);
|
||||||
|
|
||||||
// Use format() to generate expected time in local timezone
|
// Use format() to generate expected time in local timezone
|
||||||
const expectedTime = format(itemWithTime.getStartTime()!, 'HH:mm');
|
const expectedTime = format(itemStartTime, 'HH:mm');
|
||||||
expect(controller.getTitle()).toBe(expectedTime);
|
expect(controller.getTitle()).toBe(expectedTime);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ describe('ThumbnailFeatureController', () => {
|
|||||||
controller.calculate(null, itemWithTime, false);
|
controller.calculate(null, itemWithTime, false);
|
||||||
|
|
||||||
// Use format() to generate expected date string (formats in local time)
|
// Use format() to generate expected date string (formats in local time)
|
||||||
const expectedDate = format(itemWithTime.getStartTime()!, 'MMM do');
|
const expectedDate = format(itemStartTime, 'MMM do');
|
||||||
expect(controller.getSubtitles()).toContain(expectedDate);
|
expect(controller.getSubtitles()).toContain(expectedDate);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe('renderNoMedia', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not include metadata when no camera is resolvable', () => {
|
it('should not include metadata when no camera is resolvable', () => {
|
||||||
// Empty store — no default camera.
|
// Empty store -- no default camera.
|
||||||
const cameraManager = createCameraManager(createStore());
|
const cameraManager = createCameraManager(createStore());
|
||||||
|
|
||||||
const result = renderNoMedia({
|
const result = renderNoMedia({
|
||||||
|
|||||||
@@ -78,36 +78,37 @@ describe('dispatchViewContextChangeEvent', () => {
|
|||||||
const results = new QueryResults({ results: testResults });
|
const results = new QueryResults({ results: testResults });
|
||||||
const slice = results.getSlice('office');
|
const slice = results.getSlice('office');
|
||||||
expect(slice).not.toBeNull();
|
expect(slice).not.toBeNull();
|
||||||
expect(slice!.getResults()).toEqual(
|
assert(slice);
|
||||||
|
expect(slice.getResults()).toEqual(
|
||||||
testResults.filter((item) => item.getCameraID() === 'office'),
|
testResults.filter((item) => item.getCameraID() === 'office'),
|
||||||
);
|
);
|
||||||
expect(slice!.getResultsCount()).toEqual(100);
|
expect(slice.getResultsCount()).toEqual(100);
|
||||||
expect(slice!.hasResults()).toBeTruthy();
|
expect(slice.hasResults()).toBeTruthy();
|
||||||
expect(slice!.getResult(0)).not.toBeNull();
|
expect(slice.getResult(0)).not.toBeNull();
|
||||||
expect(slice!.getResult()).toBeNull();
|
expect(slice.getResult()).toBeNull();
|
||||||
expect(slice!.getSelectedIndex()).toBe(99);
|
expect(slice.getSelectedIndex()).toBe(99);
|
||||||
expect(slice!.getSelectedResult()?.getID()).toEqual('id-office-99');
|
expect(slice.getSelectedResult()?.getID()).toEqual('id-office-99');
|
||||||
expect(slice!.hasSelectedResult()).toBeTruthy();
|
expect(slice.hasSelectedResult()).toBeTruthy();
|
||||||
|
|
||||||
expect(slice!.resetSelectedResult());
|
expect(slice.resetSelectedResult());
|
||||||
expect(slice!.getSelectedResult()).toBeNull();
|
expect(slice.getSelectedResult()).toBeNull();
|
||||||
|
|
||||||
expect(slice!.selectIndex(10));
|
expect(slice.selectIndex(10));
|
||||||
expect(slice!.getSelectedIndex()).toBe(10);
|
expect(slice.getSelectedIndex()).toBe(10);
|
||||||
|
|
||||||
expect(slice!.selectIndex(10000));
|
expect(slice.selectIndex(10000));
|
||||||
expect(slice!.getSelectedIndex()).toBe(10);
|
expect(slice.getSelectedIndex()).toBe(10);
|
||||||
|
|
||||||
expect(slice!.selectIndex(-10000));
|
expect(slice.selectIndex(-10000));
|
||||||
expect(slice!.getSelectedIndex()).toBe(10);
|
expect(slice.getSelectedIndex()).toBe(10);
|
||||||
|
|
||||||
slice!.selectResultIfFound((item: ViewItem) => item.getID() === 'id-office-42');
|
slice.selectResultIfFound((item: ViewItem) => item.getID() === 'id-office-42');
|
||||||
expect(slice!.getSelectedResult()?.getID()).toBe('id-office-42');
|
expect(slice.getSelectedResult()?.getID()).toBe('id-office-42');
|
||||||
|
|
||||||
slice!.selectBestResult((itemArray: ViewItem[]) =>
|
slice.selectBestResult((itemArray: ViewItem[]) =>
|
||||||
itemArray.findIndex((item) => item.getID() === 'id-office-43'),
|
itemArray.findIndex((item) => item.getID() === 'id-office-43'),
|
||||||
);
|
);
|
||||||
expect(slice!.getSelectedResult()?.getID()).toBe('id-office-43');
|
expect(slice.getSelectedResult()?.getID()).toBe('id-office-43');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should respect select approach during construction', () => {
|
describe('should respect select approach during construction', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user