@@ -350,6 +350,20 @@ action: custom:advanced-camera-card-action
|
|||||||
advanced_camera_card_action: pause
|
advanced_camera_card_action: pause
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## `pip`
|
||||||
|
|
||||||
|
Toggle Picture-in-Picture mode. Floats the video element as a native browser overlay. Only supported by elements based on videos (e.g. notably not the `image`, or `jsmpeg` [live providers](../../cameras/live-provider.md)).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
action: custom:advanced-camera-card-action
|
||||||
|
advanced_camera_card_action: pip
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
| ----------------------------- | --------------------------------------------- |
|
||||||
|
| `action` | Must be `custom:advanced-camera-card-action`. |
|
||||||
|
| `advanced_camera_card_action` | Must be `pip`. |
|
||||||
|
|
||||||
## `play`
|
## `play`
|
||||||
|
|
||||||
Play the selected media.
|
Play the selected media.
|
||||||
@@ -702,6 +716,12 @@ elements:
|
|||||||
tap_action:
|
tap_action:
|
||||||
action: custom:advanced-camera-card-action
|
action: custom:advanced-camera-card-action
|
||||||
advanced_camera_card_action: fullscreen
|
advanced_camera_card_action: fullscreen
|
||||||
|
- type: custom:advanced-camera-card-menu-icon
|
||||||
|
icon: mdi:picture-in-picture-bottom-right
|
||||||
|
title: PIP
|
||||||
|
tap_action:
|
||||||
|
action: custom:advanced-camera-card-action
|
||||||
|
advanced_camera_card_action: pip
|
||||||
- type: custom:advanced-camera-card-menu-icon
|
- type: custom:advanced-camera-card-menu-icon
|
||||||
icon: mdi:alpha-l-circle
|
icon: mdi:alpha-l-circle
|
||||||
title: Show image view
|
title: Show image view
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ menu:
|
|||||||
| `media_player` | The `media_player` menu button: sends the visible media to a remote media player. Supports Frigate clips, snapshots and live camera (only for cameras that specify a `camera_entity` and only using the default HA stream (equivalent to the `ha` live provider)). `jsmpeg` or `webrtc-card` are not supported, although live can still be played as long as `camera_entity` is specified. In the player list, a `tap` will send the media to the player, a `hold` will stop the media on the player. |
|
| `media_player` | The `media_player` menu button: sends the visible media to a remote media player. Supports Frigate clips, snapshots and live camera (only for cameras that specify a `camera_entity` and only using the default HA stream (equivalent to the `ha` live provider)). `jsmpeg` or `webrtc-card` are not supported, although live can still be played as long as `camera_entity` is specified. In the player list, a `tap` will send the media to the player, a `hold` will stop the media on the player. |
|
||||||
| `microphone` | The `microphone` button allows usage of 2-way audio in certain configurations. See [Using 2-way audio](../usage/2-way-audio.md). |
|
| `microphone` | The `microphone` button allows usage of 2-way audio in certain configurations. See [Using 2-way audio](../usage/2-way-audio.md). |
|
||||||
| `mute` | The `mute` button: toggles the mute state of the selected media. |
|
| `mute` | The `mute` button: toggles the mute state of the selected media. |
|
||||||
|
| `pip` | The `pip` menu button: enter Picture-in-Picture mode. Floats the video element as a native browser overlay. |
|
||||||
| `play` | The `play` button: toggles the play/pause state of the selected media. |
|
| `play` | The `play` button: toggles the play/pause state of the selected media. |
|
||||||
| `ptz_controls` | The `ptz_controls` button shows or hides the PTZ controls. |
|
| `ptz_controls` | The `ptz_controls` button shows or hides the PTZ controls. |
|
||||||
| `ptz_home` | The `ptz_home` button allows easily returning the camera to default home position. |
|
| `ptz_home` | The `ptz_home` button allows easily returning the camera to default home position. |
|
||||||
@@ -57,9 +58,9 @@ menu:
|
|||||||
### Options for each button
|
### Options for each button
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `alignment` | `matching` | Whether this menu item should have an alignment that is `matching` the menu alignment or `opposing` the menu alignment. Can be used to create two separate groups of buttons on the menu. The `priority` option orders buttons within a given `alignment`. |
|
| `alignment` | `matching` | Whether this menu item should have an alignment that is `matching` the menu alignment or `opposing` the menu alignment. Can be used to create two separate groups of buttons on the menu. The `priority` option orders buttons within a given `alignment`. |
|
||||||
| `enabled` | `true` for `camera_ui`, `cameras`, `display_mode`, `download`, `folders`, `fullscreen`, `gallery`, `info`, `iris`, `live`, `media_player`, `set_review`, `substreams` and `timeline`. `false` for `clips`, `expand`, `image`, `microphone`, `mute`, `play`, `ptz_controls`, `ptz_home`, `recordings`, `reviews`, `screenshot` and `snapshots`. | Whether or not to show the button. |
|
| `enabled` | `true` for `camera_ui`, `cameras`, `display_mode`, `download`, `folders`, `fullscreen`, `gallery`, `info`, `iris`, `live`, `media_player`, `set_review`, `substreams` and `timeline`. `false` for `clips`, `expand`, `image`, `microphone`, `mute`, `pip`, `play`, `ptz_controls`, `ptz_home`, `recordings`, `reviews`, `screenshot` and `snapshots`. | Whether or not to show the button. |
|
||||||
| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. See also [custom icons](../usage/custom-icons.md). |
|
| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. See also [custom icons](../usage/custom-icons.md). |
|
||||||
| `permanent` | `false` | If `false` the menu item is hidden when the menu has the `hidden` style and the menu is closed, otherwise it is shown (and sorted to the front). |
|
| `permanent` | `false` | If `false` the menu item is hidden when the menu has the `hidden` style and the menu is closed, otherwise it is shown (and sorted to the front). |
|
||||||
| `priority` | `50` | The menu item priority. Higher priority items are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`). Priority applies separately to `matching` and `opposing` groups (see `alignment` above). Minimum `0`, maximum `100`. |
|
| `priority` | `50` | The menu item priority. Higher priority items are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`). Priority applies separately to `matching` and `opposing` groups (see `alignment` above). Minimum `0`, maximum `100`. |
|
||||||
@@ -180,6 +181,11 @@ menu:
|
|||||||
enabled: false
|
enabled: false
|
||||||
alignment: matching
|
alignment: matching
|
||||||
icon: mdi:volume-off
|
icon: mdi:volume-off
|
||||||
|
pip:
|
||||||
|
priority: 50
|
||||||
|
enabled: false
|
||||||
|
alignment: matching
|
||||||
|
icon: mdi:picture-in-picture-bottom-right
|
||||||
play:
|
play:
|
||||||
priority: 50
|
priority: 50
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -257,6 +257,29 @@ that is currently loaded supports 2-way audio. See [Using 2-way
|
|||||||
audio](usage/2-way-audio.md) for more information about the requirements that
|
audio](usage/2-way-audio.md) for more information about the requirements that
|
||||||
must be followed.
|
must be followed.
|
||||||
|
|
||||||
|
### Picture-in-Picture only shows video but not other card controls
|
||||||
|
|
||||||
|
Picture-in-Picture (PIP) uses the browser's [native video PIP
|
||||||
|
API](https://caniuse.com/picture-in-picture) which floats the raw video element
|
||||||
|
into a small window. This means:
|
||||||
|
|
||||||
|
- **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
|
||||||
|
functional on the dashboard behind it.
|
||||||
|
- **Limited browser support.** Not all browsers support the PIP API. See
|
||||||
|
[Can I use: Picture-in-Picture](https://caniuse.com/picture-in-picture) for
|
||||||
|
current browser compatibility.
|
||||||
|
- **Non-video live providers are not supported.** Providers such as `image` or
|
||||||
|
`jsmpeg` do not use a native `<video>` element so PIP is unavailable for
|
||||||
|
these.
|
||||||
|
|
||||||
|
A more fully featured PIP mode (showing the entire card in a floating window)
|
||||||
|
was explored using the experimental [Document Picture-in-Picture
|
||||||
|
API](https://caniuse.com/mdn-api_documentpictureinpicture), however it proved
|
||||||
|
unworkable: Home Assistant state updates cannot reach a card in a separate
|
||||||
|
document, and browser-managed styles (`adoptedStyleSheets`) are cleared when
|
||||||
|
elements move between documents — resulting in an unstyled, non-updating card.
|
||||||
|
|
||||||
### New version not working in Chrome
|
### New version not working in Chrome
|
||||||
|
|
||||||
When upgrading the card it's recommended to reset the frontend cache. Sometimes
|
When upgrading the card it's recommended to reset the frontend cache. Sometimes
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
|
||||||
|
import { CardActionsAPI } from '../../types';
|
||||||
|
import { AdvancedCameraCardAction } from './base';
|
||||||
|
|
||||||
|
export class PIPAction extends AdvancedCameraCardAction<GeneralActionConfig> {
|
||||||
|
public async execute(api: CardActionsAPI): Promise<void> {
|
||||||
|
await super.execute(api);
|
||||||
|
await api.getPIPManager().togglePIP();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import { NavigateAction } from './actions/navigate';
|
|||||||
import { NoneAction } from './actions/none';
|
import { NoneAction } from './actions/none';
|
||||||
import { PauseAction } from './actions/pause';
|
import { PauseAction } from './actions/pause';
|
||||||
import { PerformActionAction } from './actions/perform-action';
|
import { PerformActionAction } from './actions/perform-action';
|
||||||
|
import { PIPAction } from './actions/pip';
|
||||||
import { PlayAction } from './actions/play';
|
import { PlayAction } from './actions/play';
|
||||||
import { PTZAction } from './actions/ptz';
|
import { PTZAction } from './actions/ptz';
|
||||||
import { PTZControlsAction } from './actions/ptz-controls';
|
import { PTZControlsAction } from './actions/ptz-controls';
|
||||||
@@ -146,6 +147,8 @@ export class ActionFactory {
|
|||||||
return new PlayAction(context, action, options?.config);
|
return new PlayAction(context, action, options?.config);
|
||||||
case 'pause':
|
case 'pause':
|
||||||
return new PauseAction(context, action, options?.config);
|
return new PauseAction(context, action, options?.config);
|
||||||
|
case 'pip':
|
||||||
|
return new PIPAction(context, action, options?.config);
|
||||||
case 'screenshot':
|
case 'screenshot':
|
||||||
return new ScreenshotAction(context, action, options?.config);
|
return new ScreenshotAction(context, action, options?.config);
|
||||||
case 'display_mode_select':
|
case 'display_mode_select':
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export class CardElementManager {
|
|||||||
this._api.getExpandManager().initialize();
|
this._api.getExpandManager().initialize();
|
||||||
this._api.getMediaLoadedInfoManager().initialize();
|
this._api.getMediaLoadedInfoManager().initialize();
|
||||||
this._api.getMicrophoneManager().initialize();
|
this._api.getMicrophoneManager().initialize();
|
||||||
|
this._api.getPIPManager().initialize();
|
||||||
this._api.getKeyboardStateManager().initialize();
|
this._api.getKeyboardStateManager().initialize();
|
||||||
|
|
||||||
// These initializers are called when the config is updated, but on initial
|
// These initializers are called when the config is updated, but on initial
|
||||||
@@ -169,6 +170,7 @@ export class CardElementManager {
|
|||||||
// When the dashboard 'tab' is changed, the media is effectively unloaded.
|
// When the dashboard 'tab' is changed, the media is effectively unloaded.
|
||||||
this._api.getMediaLoadedInfoManager().clear();
|
this._api.getMediaLoadedInfoManager().clear();
|
||||||
this._api.getFullscreenManager().disconnect();
|
this._api.getFullscreenManager().disconnect();
|
||||||
|
this._api.getPIPManager().uninitialize();
|
||||||
this._api.getKeyboardStateManager().uninitialize();
|
this._api.getKeyboardStateManager().uninitialize();
|
||||||
this._api.getActionsManager().uninitialize();
|
this._api.getActionsManager().uninitialize();
|
||||||
this._api.getDefaultManager().uninitialize();
|
this._api.getDefaultManager().uninitialize();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ReactiveController } from 'lit';
|
import { ReactiveController } from 'lit';
|
||||||
import { CameraManager } from '../camera-manager/manager';
|
import { CameraManager } from '../camera-manager/manager';
|
||||||
import { EffectsManager } from './effects/effects-manager';
|
|
||||||
import { ConditionStateManager } from '../conditions/state-manager';
|
import { ConditionStateManager } from '../conditions/state-manager';
|
||||||
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
||||||
import { DeviceRegistryManager } from '../ha/registry/device';
|
import { DeviceRegistryManager } from '../ha/registry/device';
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
} from './card-element-manager';
|
} from './card-element-manager';
|
||||||
import { ConfigManager } from './config/config-manager';
|
import { ConfigManager } from './config/config-manager';
|
||||||
import { DefaultManager } from './default-manager';
|
import { DefaultManager } from './default-manager';
|
||||||
|
import { EffectsManager } from './effects/effects-manager';
|
||||||
import { ExpandManager } from './expand-manager';
|
import { ExpandManager } from './expand-manager';
|
||||||
import { FoldersManager } from './folders/manager';
|
import { FoldersManager } from './folders/manager';
|
||||||
import { FullscreenManager } from './fullscreen/fullscreen-manager';
|
import { FullscreenManager } from './fullscreen/fullscreen-manager';
|
||||||
@@ -32,6 +32,7 @@ import { MediaPlayerManager } from './media-player-manager';
|
|||||||
import { MessageManager } from './message-manager';
|
import { MessageManager } from './message-manager';
|
||||||
import { MicrophoneManager } from './microphone-manager';
|
import { MicrophoneManager } from './microphone-manager';
|
||||||
import { OverlayMessageManager } from './overlay-message-manager';
|
import { OverlayMessageManager } from './overlay-message-manager';
|
||||||
|
import { PIPManager } from './pip-manager';
|
||||||
import { QueryStringManager } from './query-string-manager';
|
import { QueryStringManager } from './query-string-manager';
|
||||||
import { StatusBarItemManager } from './status-bar-item-manager';
|
import { StatusBarItemManager } from './status-bar-item-manager';
|
||||||
import { StyleManager } from './style-manager';
|
import { StyleManager } from './style-manager';
|
||||||
@@ -58,6 +59,7 @@ import {
|
|||||||
CardMessageAPI,
|
CardMessageAPI,
|
||||||
CardMicrophoneAPI,
|
CardMicrophoneAPI,
|
||||||
CardOverlayMessageAPI,
|
CardOverlayMessageAPI,
|
||||||
|
CardPIPAPI,
|
||||||
CardQueryStringAPI,
|
CardQueryStringAPI,
|
||||||
CardStyleAPI,
|
CardStyleAPI,
|
||||||
CardTriggersAPI,
|
CardTriggersAPI,
|
||||||
@@ -80,6 +82,7 @@ export class CardController
|
|||||||
CardExpandAPI,
|
CardExpandAPI,
|
||||||
CardFullscreenAPI,
|
CardFullscreenAPI,
|
||||||
CardHASSAPI,
|
CardHASSAPI,
|
||||||
|
CardPIPAPI,
|
||||||
CardInitializerAPI,
|
CardInitializerAPI,
|
||||||
CardInteractionAPI,
|
CardInteractionAPI,
|
||||||
CardKeyboardStateAPI,
|
CardKeyboardStateAPI,
|
||||||
@@ -123,6 +126,7 @@ export class CardController
|
|||||||
private _messageManager = new MessageManager(this);
|
private _messageManager = new MessageManager(this);
|
||||||
private _microphoneManager = new MicrophoneManager(this);
|
private _microphoneManager = new MicrophoneManager(this);
|
||||||
private _overlayMessageManager = new OverlayMessageManager(this);
|
private _overlayMessageManager = new OverlayMessageManager(this);
|
||||||
|
private _pipManager = new PIPManager(this);
|
||||||
private _queryStringManager = new QueryStringManager(this);
|
private _queryStringManager = new QueryStringManager(this);
|
||||||
private _statusBarItemManager = new StatusBarItemManager(this);
|
private _statusBarItemManager = new StatusBarItemManager(this);
|
||||||
private _styleManager = new StyleManager(this);
|
private _styleManager = new StyleManager(this);
|
||||||
@@ -252,6 +256,10 @@ export class CardController
|
|||||||
return this._overlayMessageManager;
|
return this._overlayMessageManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPIPManager(): PIPManager {
|
||||||
|
return this._pipManager;
|
||||||
|
}
|
||||||
|
|
||||||
public getQueryStringManager(): QueryStringManager {
|
public getQueryStringManager(): QueryStringManager {
|
||||||
return this._queryStringManager;
|
return this._queryStringManager;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { ConditionStateChange } from '../conditions/types';
|
||||||
|
import { PIPElement } from '../types';
|
||||||
|
import { CardPIPAPI } from './types';
|
||||||
|
|
||||||
|
export class PIPManager {
|
||||||
|
private _api: CardPIPAPI;
|
||||||
|
private _videoElement: PIPElement | null = null;
|
||||||
|
private _disconnect: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(api: CardPIPAPI) {
|
||||||
|
this._api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isSupported(): boolean {
|
||||||
|
return !!document.pictureInPictureEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public initialize(): void {
|
||||||
|
this._api.getConditionStateManager().addListener(this._stateChangeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public uninitialize(): void {
|
||||||
|
this._trackElement(null);
|
||||||
|
this._api.getConditionStateManager().removeListener(this._stateChangeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isInPIP(): boolean {
|
||||||
|
return !!document.pictureInPictureElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isAvailable(): boolean {
|
||||||
|
return PIPManager.isSupported() && !!this._videoElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async togglePIP(): Promise<void> {
|
||||||
|
if (document.pictureInPictureElement) {
|
||||||
|
await document.exitPictureInPicture();
|
||||||
|
} else if (this._videoElement && PIPManager.isSupported()) {
|
||||||
|
await this._videoElement.requestPictureInPicture();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _stateChangeHandler = (change: ConditionStateChange): void => {
|
||||||
|
const oldElement =
|
||||||
|
change.old.mediaLoadedInfo?.mediaPlayerController?.getPIPElement() ?? null;
|
||||||
|
const newElement =
|
||||||
|
change.new.mediaLoadedInfo?.mediaPlayerController?.getPIPElement() ?? null;
|
||||||
|
|
||||||
|
if (oldElement !== newElement) {
|
||||||
|
// Exit PIP when the video element is destroyed (e.g. view change) to
|
||||||
|
// avoid a black/empty PIP window.
|
||||||
|
if (!newElement && this.isInPIP()) {
|
||||||
|
// Ignore errors in existing PIP.
|
||||||
|
document.exitPictureInPicture().catch(() => {});
|
||||||
|
}
|
||||||
|
this._trackElement(newElement);
|
||||||
|
this._api.getCardElementManager().update();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private _trackElement(element: PIPElement | null): void {
|
||||||
|
this._disconnect?.();
|
||||||
|
this._disconnect = null;
|
||||||
|
this._videoElement = element;
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
const handler = (): void => this._api.getCardElementManager().update();
|
||||||
|
element.addEventListener('enterpictureinpicture', handler);
|
||||||
|
element.addEventListener('leavepictureinpicture', handler);
|
||||||
|
this._disconnect = () => {
|
||||||
|
element.removeEventListener('enterpictureinpicture', handler);
|
||||||
|
element.removeEventListener('leavepictureinpicture', handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import type { MediaPlayerManager } from './media-player-manager';
|
|||||||
import type { MessageManager } from './message-manager';
|
import type { MessageManager } from './message-manager';
|
||||||
import type { MicrophoneManager } from './microphone-manager';
|
import type { MicrophoneManager } from './microphone-manager';
|
||||||
import type { OverlayMessageManager } from './overlay-message-manager';
|
import type { OverlayMessageManager } from './overlay-message-manager';
|
||||||
|
import type { PIPManager } from './pip-manager';
|
||||||
import type { QueryStringManager } from './query-string-manager';
|
import type { QueryStringManager } from './query-string-manager';
|
||||||
import type { StatusBarItemManager } from './status-bar-item-manager';
|
import type { StatusBarItemManager } from './status-bar-item-manager';
|
||||||
import type { StyleManager } from './style-manager';
|
import type { StyleManager } from './style-manager';
|
||||||
@@ -54,6 +55,7 @@ export interface CardActionsAPI {
|
|||||||
getMessageManager(): MessageManager;
|
getMessageManager(): MessageManager;
|
||||||
getMicrophoneManager(): MicrophoneManager;
|
getMicrophoneManager(): MicrophoneManager;
|
||||||
getOverlayMessageManager(): OverlayMessageManager;
|
getOverlayMessageManager(): OverlayMessageManager;
|
||||||
|
getPIPManager(): PIPManager;
|
||||||
getStatusBarItemManager(): StatusBarItemManager;
|
getStatusBarItemManager(): StatusBarItemManager;
|
||||||
getTriggersManager(): TriggersManager;
|
getTriggersManager(): TriggersManager;
|
||||||
getViewItemManager(): ViewItemManager;
|
getViewItemManager(): ViewItemManager;
|
||||||
@@ -150,6 +152,7 @@ export interface CardElementAPI {
|
|||||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||||
getMediaPlayerManager(): MediaPlayerManager;
|
getMediaPlayerManager(): MediaPlayerManager;
|
||||||
getMicrophoneManager(): MicrophoneManager;
|
getMicrophoneManager(): MicrophoneManager;
|
||||||
|
getPIPManager(): PIPManager;
|
||||||
getQueryStringManager(): QueryStringManager;
|
getQueryStringManager(): QueryStringManager;
|
||||||
getViewManager(): ViewManager;
|
getViewManager(): ViewManager;
|
||||||
}
|
}
|
||||||
@@ -174,6 +177,11 @@ export interface CardFullscreenAPI {
|
|||||||
getMediaPlayerManager(): MediaPlayerManager;
|
getMediaPlayerManager(): MediaPlayerManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CardPIPAPI {
|
||||||
|
getCardElementManager(): CardElementManager;
|
||||||
|
getConditionStateManager(): ConditionStateManager;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CardHASSAPI {
|
export interface CardHASSAPI {
|
||||||
getCameraManager(): CameraManager;
|
getCameraManager(): CameraManager;
|
||||||
getCardElementManager(): CardElementManager;
|
getCardElementManager(): CardElementManager;
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ class AdvancedCameraCard extends LitElement {
|
|||||||
inExpandedMode: this._controller.getExpandManager().isExpanded(),
|
inExpandedMode: this._controller.getExpandManager().isExpanded(),
|
||||||
mediaPlayerController: this._controller.getMediaPlayerManager(),
|
mediaPlayerController: this._controller.getMediaPlayerManager(),
|
||||||
microphoneManager: this._controller.getMicrophoneManager(),
|
microphoneManager: this._controller.getMicrophoneManager(),
|
||||||
|
pipManager: this._controller.getPIPManager(),
|
||||||
showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(),
|
showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(),
|
||||||
view: view,
|
view: view,
|
||||||
viewManager: this._controller.getViewManager(),
|
viewManager: this._controller.getViewManager(),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LitElement } from 'lit';
|
import { LitElement } from 'lit';
|
||||||
import { FullscreenElement, MediaPlayerController } from '../../types';
|
import { FullscreenElement, MediaPlayerController, PIPElement } from '../../types';
|
||||||
import { screenshotImage } from '../../utils/screenshot';
|
import { screenshotImage } from '../../utils/screenshot';
|
||||||
|
|
||||||
export class ImageMediaPlayerController implements MediaPlayerController {
|
export class ImageMediaPlayerController implements MediaPlayerController {
|
||||||
@@ -58,4 +58,8 @@ export class ImageMediaPlayerController implements MediaPlayerController {
|
|||||||
public getFullscreenElement(): FullscreenElement | null {
|
public getFullscreenElement(): FullscreenElement | null {
|
||||||
return this._getImageCallback() ?? null;
|
return this._getImageCallback() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPIPElement(): PIPElement | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||||
import { LitElement } from 'lit';
|
import { LitElement } from 'lit';
|
||||||
import { FullscreenElement, MediaPlayerController } from '../../types';
|
import { FullscreenElement, MediaPlayerController, PIPElement } from '../../types';
|
||||||
|
|
||||||
export class JSMPEGMediaPlayerController implements MediaPlayerController {
|
export class JSMPEGMediaPlayerController implements MediaPlayerController {
|
||||||
private _host: LitElement;
|
private _host: LitElement;
|
||||||
@@ -70,4 +70,9 @@ export class JSMPEGMediaPlayerController implements MediaPlayerController {
|
|||||||
public getFullscreenElement(): FullscreenElement | null {
|
public getFullscreenElement(): FullscreenElement | null {
|
||||||
return this._getCanvasElementCallback() ?? null;
|
return this._getCanvasElementCallback() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPIPElement(): PIPElement | null {
|
||||||
|
// JSMpeg renders to a canvas, not a video element.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LitElement } from 'lit';
|
import { LitElement } from 'lit';
|
||||||
import { FullscreenElement, MediaPlayerController } from '../../types';
|
import { FullscreenElement, MediaPlayerController, PIPElement } from '../../types';
|
||||||
import { CachedValueController } from '../cached-value-controller';
|
import { CachedValueController } from '../cached-value-controller';
|
||||||
|
|
||||||
export class UpdatingImageMediaPlayerController implements MediaPlayerController {
|
export class UpdatingImageMediaPlayerController implements MediaPlayerController {
|
||||||
@@ -61,4 +61,8 @@ export class UpdatingImageMediaPlayerController implements MediaPlayerController
|
|||||||
public getFullscreenElement(): FullscreenElement | null {
|
public getFullscreenElement(): FullscreenElement | null {
|
||||||
return this._getImageCallback() ?? null;
|
return this._getImageCallback() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPIPElement(): PIPElement | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LitElement } from 'lit';
|
import { LitElement } from 'lit';
|
||||||
import { FullscreenElement, MediaPlayerController } from '../../types';
|
import { FullscreenElement, MediaPlayerController, PIPElement } from '../../types';
|
||||||
import { hideMediaControlsTemporarily, setControlsOnVideo } from '../../utils/controls';
|
import { hideMediaControlsTemporarily, setControlsOnVideo } from '../../utils/controls';
|
||||||
import { screenshotVideo } from '../../utils/screenshot';
|
import { screenshotVideo } from '../../utils/screenshot';
|
||||||
|
|
||||||
@@ -107,4 +107,8 @@ export class VideoMediaPlayerController implements MediaPlayerController {
|
|||||||
public getFullscreenElement(): FullscreenElement | null {
|
public getFullscreenElement(): FullscreenElement | null {
|
||||||
return this._getVideoCallback() ?? null;
|
return this._getVideoCallback() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPIPElement(): PIPElement | null {
|
||||||
|
return this._getVideoCallback() ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FoldersManager } from '../card-controller/folders/manager';
|
|||||||
import { FullscreenManager } from '../card-controller/fullscreen/fullscreen-manager';
|
import { FullscreenManager } from '../card-controller/fullscreen/fullscreen-manager';
|
||||||
import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
||||||
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
||||||
|
import { PIPManager } from '../card-controller/pip-manager';
|
||||||
import { ViewManager } from '../card-controller/view/view-manager';
|
import { ViewManager } from '../card-controller/view/view-manager';
|
||||||
import {
|
import {
|
||||||
AdvancedCameraCardView,
|
AdvancedCameraCardView,
|
||||||
@@ -45,6 +46,7 @@ export interface MenuButtonControllerOptions {
|
|||||||
inExpandedMode?: boolean;
|
inExpandedMode?: boolean;
|
||||||
microphoneManager?: MicrophoneManager | null;
|
microphoneManager?: MicrophoneManager | null;
|
||||||
mediaPlayerController?: MediaPlayerManager | null;
|
mediaPlayerController?: MediaPlayerManager | null;
|
||||||
|
pipManager?: PIPManager | null;
|
||||||
viewManager?: ViewManager | null;
|
viewManager?: ViewManager | null;
|
||||||
view?: View | null;
|
view?: View | null;
|
||||||
}
|
}
|
||||||
@@ -100,6 +102,7 @@ export class MenuButtonController {
|
|||||||
),
|
),
|
||||||
this._getExpandButton(config, options?.inExpandedMode),
|
this._getExpandButton(config, options?.inExpandedMode),
|
||||||
this._getFullscreenButton(config, options?.fullscreenManager),
|
this._getFullscreenButton(config, options?.fullscreenManager),
|
||||||
|
this._getPIPButton(config, options?.pipManager),
|
||||||
this._getCastButton(
|
this._getCastButton(
|
||||||
hass,
|
hass,
|
||||||
config,
|
config,
|
||||||
@@ -550,6 +553,25 @@ export class MenuButtonController {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _getPIPButton(
|
||||||
|
config: AdvancedCameraCardConfig,
|
||||||
|
pipManager?: PIPManager | null,
|
||||||
|
): MenuItem | null {
|
||||||
|
const inPIP = pipManager?.isInPIP();
|
||||||
|
return pipManager?.isAvailable()
|
||||||
|
? {
|
||||||
|
icon: inPIP
|
||||||
|
? 'mdi:picture-in-picture-bottom-right-outline'
|
||||||
|
: 'mdi:picture-in-picture-bottom-right',
|
||||||
|
...config.menu.buttons.pip,
|
||||||
|
type: 'custom:advanced-camera-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.pip'),
|
||||||
|
tap_action: createGeneralAction('pip'),
|
||||||
|
style: inPIP ? this._getEmphasizedStyle() : {},
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
private _getCastButton(
|
private _getCastButton(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config: AdvancedCameraCardConfig,
|
config: AdvancedCameraCardConfig,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const GENERAL_ACTIONS = [
|
|||||||
'microphone_unmute',
|
'microphone_unmute',
|
||||||
'mute',
|
'mute',
|
||||||
'pause',
|
'pause',
|
||||||
|
'pip',
|
||||||
'play',
|
'play',
|
||||||
'reload',
|
'reload',
|
||||||
'screenshot',
|
'screenshot',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { statusBarItemBaseSchema } from '../common/status-bar';
|
|
||||||
import { severitySchema } from '../common/severity';
|
import { severitySchema } from '../common/severity';
|
||||||
|
import { statusBarItemBaseSchema } from '../common/status-bar';
|
||||||
import { advancedCameraCardCustomActionsBaseSchema } from './custom/base';
|
import { advancedCameraCardCustomActionsBaseSchema } from './custom/base';
|
||||||
import { cameraSelectActionConfigSchema } from './custom/camera-select';
|
import { cameraSelectActionConfigSchema } from './custom/camera-select';
|
||||||
import { viewDisplayModeActionConfigSchema } from './custom/display-mode';
|
import { viewDisplayModeActionConfigSchema } from './custom/display-mode';
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const menuConfigDefault = {
|
|||||||
type: 'momentary' as const,
|
type: 'momentary' as const,
|
||||||
},
|
},
|
||||||
mute: { ...hiddenButtonDefault },
|
mute: { ...hiddenButtonDefault },
|
||||||
|
pip: { ...hiddenButtonDefault },
|
||||||
play: { ...hiddenButtonDefault },
|
play: { ...hiddenButtonDefault },
|
||||||
ptz_controls: { ...hiddenButtonDefault },
|
ptz_controls: { ...hiddenButtonDefault },
|
||||||
recordings: { ...hiddenButtonDefault },
|
recordings: { ...hiddenButtonDefault },
|
||||||
@@ -114,6 +115,7 @@ export const menuConfigSchema = z
|
|||||||
})
|
})
|
||||||
.default(menuConfigDefault.buttons.microphone),
|
.default(menuConfigDefault.buttons.microphone),
|
||||||
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
||||||
|
pip: hiddenButtonSchema.default(menuConfigDefault.buttons.pip),
|
||||||
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
||||||
ptz_controls: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_controls),
|
ptz_controls: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_controls),
|
||||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
||||||
|
|||||||
@@ -3067,6 +3067,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
)}`,
|
)}`,
|
||||||
)}
|
)}
|
||||||
${this._renderMenuButton('mute') /* */}
|
${this._renderMenuButton('mute') /* */}
|
||||||
|
${this._renderMenuButton('pip') /* */}
|
||||||
${this._renderMenuButton('play')}
|
${this._renderMenuButton('play')}
|
||||||
${this._renderMenuButton('ptz_controls')}
|
${this._renderMenuButton('ptz_controls')}
|
||||||
${this._renderMenuButton('ptz_home')}
|
${this._renderMenuButton('ptz_home')}
|
||||||
|
|||||||
@@ -482,6 +482,7 @@
|
|||||||
"microphone": "Microphone",
|
"microphone": "Microphone",
|
||||||
"mute": "Mute / Unmute",
|
"mute": "Mute / Unmute",
|
||||||
"permanent": "Show when the menu is hidden",
|
"permanent": "Show when the menu is hidden",
|
||||||
|
"pip": "Picture in Picture",
|
||||||
"play": "Play / Pause",
|
"play": "Play / Pause",
|
||||||
"priority": "Priority",
|
"priority": "Priority",
|
||||||
"ptz_controls": "Show PTZ controls",
|
"ptz_controls": "Show PTZ controls",
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export type WebkitHTMLVideoElement = HTMLVideoElement & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type FullscreenElement = HTMLElement;
|
export type FullscreenElement = HTMLElement;
|
||||||
|
export type PIPElement = HTMLVideoElement;
|
||||||
|
|
||||||
export interface MediaPlayerController {
|
export interface MediaPlayerController {
|
||||||
play(): Promise<void>;
|
play(): Promise<void>;
|
||||||
@@ -103,6 +104,7 @@ export interface MediaPlayerController {
|
|||||||
setControls(controls?: boolean): Promise<void>;
|
setControls(controls?: boolean): Promise<void>;
|
||||||
isPaused(): boolean;
|
isPaused(): boolean;
|
||||||
getFullscreenElement(): FullscreenElement | null;
|
getFullscreenElement(): FullscreenElement | null;
|
||||||
|
getPIPElement(): PIPElement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaPlayer {
|
export interface MediaPlayer {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { expect, it } from 'vitest';
|
||||||
|
import { PIPAction } from '../../../../src/card-controller/actions/actions/pip';
|
||||||
|
import { createCardAPI } from '../../../test-utils';
|
||||||
|
|
||||||
|
it('should toggle PIP', async () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const action = new PIPAction(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
advanced_camera_card_action: 'pip',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await action.execute(api);
|
||||||
|
|
||||||
|
expect(api.getPIPManager().togglePIP).toBeCalled();
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ import { MuteAction } from '../../../src/card-controller/actions/actions/mute';
|
|||||||
import { NavigateAction } from '../../../src/card-controller/actions/actions/navigate';
|
import { NavigateAction } from '../../../src/card-controller/actions/actions/navigate';
|
||||||
import { NoneAction } from '../../../src/card-controller/actions/actions/none';
|
import { NoneAction } from '../../../src/card-controller/actions/actions/none';
|
||||||
import { PauseAction } from '../../../src/card-controller/actions/actions/pause';
|
import { PauseAction } from '../../../src/card-controller/actions/actions/pause';
|
||||||
|
import { PIPAction } from '../../../src/card-controller/actions/actions/pip';
|
||||||
import { PerformActionAction } from '../../../src/card-controller/actions/actions/perform-action';
|
import { PerformActionAction } from '../../../src/card-controller/actions/actions/perform-action';
|
||||||
import { PlayAction } from '../../../src/card-controller/actions/actions/play';
|
import { PlayAction } from '../../../src/card-controller/actions/actions/play';
|
||||||
import { PTZAction } from '../../../src/card-controller/actions/actions/ptz';
|
import { PTZAction } from '../../../src/card-controller/actions/actions/ptz';
|
||||||
@@ -152,6 +153,7 @@ describe('ActionFactory', () => {
|
|||||||
],
|
],
|
||||||
[{ advanced_camera_card_action: 'mute' as const }, MuteAction],
|
[{ advanced_camera_card_action: 'mute' as const }, MuteAction],
|
||||||
[{ advanced_camera_card_action: 'pause' as const }, PauseAction],
|
[{ advanced_camera_card_action: 'pause' as const }, PauseAction],
|
||||||
|
[{ advanced_camera_card_action: 'pip' as const }, PIPAction],
|
||||||
[{ advanced_camera_card_action: 'play' as const }, PlayAction],
|
[{ advanced_camera_card_action: 'play' as const }, PlayAction],
|
||||||
[{ advanced_camera_card_action: 'ptz_digital' as const }, PTZDigitalAction],
|
[{ advanced_camera_card_action: 'ptz_digital' as const }, PTZDigitalAction],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { MediaPlayerManager } from '../../src/card-controller/media-player-manag
|
|||||||
import { MessageManager } from '../../src/card-controller/message-manager';
|
import { MessageManager } from '../../src/card-controller/message-manager';
|
||||||
import { MicrophoneManager } from '../../src/card-controller/microphone-manager';
|
import { MicrophoneManager } from '../../src/card-controller/microphone-manager';
|
||||||
import { OverlayMessageManager } from '../../src/card-controller/overlay-message-manager';
|
import { OverlayMessageManager } from '../../src/card-controller/overlay-message-manager';
|
||||||
|
import { PIPManager } from '../../src/card-controller/pip-manager';
|
||||||
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
|
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
|
||||||
import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager';
|
import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager';
|
||||||
import { StyleManager } from '../../src/card-controller/style-manager';
|
import { StyleManager } from '../../src/card-controller/style-manager';
|
||||||
@@ -54,6 +55,7 @@ vi.mock('../../src/card-controller/media-player-manager');
|
|||||||
vi.mock('../../src/card-controller/message-manager');
|
vi.mock('../../src/card-controller/message-manager');
|
||||||
vi.mock('../../src/card-controller/microphone-manager');
|
vi.mock('../../src/card-controller/microphone-manager');
|
||||||
vi.mock('../../src/card-controller/overlay-message-manager');
|
vi.mock('../../src/card-controller/overlay-message-manager');
|
||||||
|
vi.mock('../../src/card-controller/pip-manager');
|
||||||
vi.mock('../../src/card-controller/query-string-manager');
|
vi.mock('../../src/card-controller/query-string-manager');
|
||||||
vi.mock('../../src/card-controller/status-bar-item-manager');
|
vi.mock('../../src/card-controller/status-bar-item-manager');
|
||||||
vi.mock('../../src/card-controller/style-manager');
|
vi.mock('../../src/card-controller/style-manager');
|
||||||
@@ -230,6 +232,12 @@ describe('CardController', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('getPIPManager', () => {
|
||||||
|
expect(createController().getPIPManager()).toBe(
|
||||||
|
vi.mocked(PIPManager).mock.instances[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('getMicrophoneManager', () => {
|
it('getMicrophoneManager', () => {
|
||||||
expect(createController().getMicrophoneManager()).toBe(
|
expect(createController().getMicrophoneManager()).toBe(
|
||||||
vi.mocked(MicrophoneManager).mock.instances[0],
|
vi.mocked(MicrophoneManager).mock.instances[0],
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import { CardController } from '../../src/card-controller/controller';
|
||||||
|
import { PIPManager } from '../../src/card-controller/pip-manager';
|
||||||
|
import { ConditionStateManager } from '../../src/conditions/state-manager';
|
||||||
|
import { MediaPlayerController } from '../../src/types';
|
||||||
|
import { createCardAPI, createMediaLoadedInfo, flushPromises } from '../test-utils';
|
||||||
|
|
||||||
|
const stubPIPSupported = (enabled = true): void => {
|
||||||
|
Object.defineProperty(document, 'pictureInPictureEnabled', {
|
||||||
|
value: enabled,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stubPIPElement = (element: Element | null): void => {
|
||||||
|
Object.defineProperty(document, 'pictureInPictureElement', {
|
||||||
|
value: element,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createVideoElement = (): HTMLVideoElement => {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.requestPictureInPicture = vi.fn().mockResolvedValue({});
|
||||||
|
return video;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMediaPlayerControllerWithPIP = (
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
): MediaPlayerController => {
|
||||||
|
const controller = mock<MediaPlayerController>();
|
||||||
|
controller.getPIPElement.mockReturnValue(video);
|
||||||
|
return controller;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupWithVideo = (api: CardController) => {
|
||||||
|
const stateManager = new ConditionStateManager();
|
||||||
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||||
|
|
||||||
|
const manager = new PIPManager(api);
|
||||||
|
manager.initialize();
|
||||||
|
|
||||||
|
const video = createVideoElement();
|
||||||
|
stateManager.setState({
|
||||||
|
mediaLoadedInfo: createMediaLoadedInfo({
|
||||||
|
mediaPlayerController: createMediaPlayerControllerWithPIP(video),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { manager, stateManager, video };
|
||||||
|
};
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('PIPManager', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Reset document PIP properties.
|
||||||
|
Object.defineProperty(document, 'pictureInPictureEnabled', {
|
||||||
|
value: undefined,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(document, 'pictureInPictureElement', {
|
||||||
|
value: undefined,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSupported', () => {
|
||||||
|
it('returns true when pictureInPictureEnabled is true', () => {
|
||||||
|
stubPIPSupported(true);
|
||||||
|
|
||||||
|
expect(PIPManager.isSupported()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when pictureInPictureEnabled is falsy', () => {
|
||||||
|
expect(PIPManager.isSupported()).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialize', () => {
|
||||||
|
it('registers a listener with the condition state manager', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new PIPManager(api);
|
||||||
|
|
||||||
|
manager.initialize();
|
||||||
|
|
||||||
|
expect(api.getConditionStateManager().addListener).toBeCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('uninitialize', () => {
|
||||||
|
it('removes the listener from the condition state manager', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new PIPManager(api);
|
||||||
|
|
||||||
|
manager.uninitialize();
|
||||||
|
|
||||||
|
expect(api.getConditionStateManager().removeListener).toBeCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears video element reference', () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const { manager } = setupWithVideo(api);
|
||||||
|
|
||||||
|
expect(manager.isAvailable()).toBe(true);
|
||||||
|
|
||||||
|
manager.uninitialize();
|
||||||
|
|
||||||
|
expect(manager.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes event listeners from tracked video', () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const { manager, video } = setupWithVideo(api);
|
||||||
|
const removeSpy = vi.spyOn(video, 'removeEventListener');
|
||||||
|
|
||||||
|
manager.uninitialize();
|
||||||
|
|
||||||
|
expect(removeSpy).toBeCalledWith('enterpictureinpicture', expect.any(Function));
|
||||||
|
expect(removeSpy).toBeCalledWith('leavepictureinpicture', expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isInPIP', () => {
|
||||||
|
it('returns false when no video element is tracked', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new PIPManager(api);
|
||||||
|
|
||||||
|
expect(manager.isInPIP()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when any element is in PIP', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new PIPManager(api);
|
||||||
|
|
||||||
|
stubPIPElement(document.createElement('video'));
|
||||||
|
|
||||||
|
expect(manager.isInPIP()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAvailable', () => {
|
||||||
|
it('returns true when PIP is supported and a video is loaded', () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const { manager } = setupWithVideo(api);
|
||||||
|
|
||||||
|
expect(manager.isAvailable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when PIP is not supported', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new PIPManager(api);
|
||||||
|
manager.initialize();
|
||||||
|
|
||||||
|
expect(manager.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when no video is loaded', () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new PIPManager(api);
|
||||||
|
manager.initialize();
|
||||||
|
|
||||||
|
expect(manager.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('media element tracking', () => {
|
||||||
|
it('tracks the video element from mediaLoadedInfo', () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const { manager } = setupWithVideo(api);
|
||||||
|
|
||||||
|
expect(manager.isAvailable()).toBe(true);
|
||||||
|
expect(api.getCardElementManager().update).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes listeners when the media element changes', () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const { stateManager, video } = setupWithVideo(api);
|
||||||
|
const removeSpy = vi.spyOn(video, 'removeEventListener');
|
||||||
|
|
||||||
|
const video2 = createVideoElement();
|
||||||
|
stateManager.setState({
|
||||||
|
mediaLoadedInfo: createMediaLoadedInfo({
|
||||||
|
mediaPlayerController: createMediaPlayerControllerWithPIP(video2),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(removeSpy).toBeCalledWith('enterpictureinpicture', expect.any(Function));
|
||||||
|
expect(removeSpy).toBeCalledWith('leavepictureinpicture', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-track when element is unchanged', () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const { stateManager, video } = setupWithVideo(api);
|
||||||
|
const addSpy = vi.spyOn(video, 'addEventListener');
|
||||||
|
addSpy.mockClear();
|
||||||
|
|
||||||
|
stateManager.setState({ interaction: true });
|
||||||
|
|
||||||
|
expect(addSpy).not.toBeCalledWith('enterpictureinpicture', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears video element when media is unloaded', () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const { manager, stateManager } = setupWithVideo(api);
|
||||||
|
|
||||||
|
expect(manager.isAvailable()).toBe(true);
|
||||||
|
|
||||||
|
stateManager.setState({
|
||||||
|
mediaLoadedInfo: createMediaLoadedInfo(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.isAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits PIP when the video element is destroyed', () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const { stateManager, video } = setupWithVideo(api);
|
||||||
|
|
||||||
|
stubPIPElement(video);
|
||||||
|
const exitPIP = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(document, 'exitPictureInPicture', {
|
||||||
|
value: exitPIP,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
stateManager.setState({
|
||||||
|
mediaLoadedInfo: createMediaLoadedInfo(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitPIP).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles exitPictureInPicture rejection gracefully', async () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const { stateManager, video } = setupWithVideo(api);
|
||||||
|
|
||||||
|
stubPIPElement(video);
|
||||||
|
const exitPIP = vi.fn().mockRejectedValue(new Error('fail'));
|
||||||
|
Object.defineProperty(document, 'exitPictureInPicture', {
|
||||||
|
value: exitPIP,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
stateManager.setState({
|
||||||
|
mediaLoadedInfo: createMediaLoadedInfo(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitPIP).toBeCalled();
|
||||||
|
|
||||||
|
// Ensure the rejection is caught and does not throw.
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not exit PIP when video element changes to a new one', () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const { stateManager, video } = setupWithVideo(api);
|
||||||
|
|
||||||
|
stubPIPElement(video);
|
||||||
|
const exitPIP = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(document, 'exitPictureInPicture', {
|
||||||
|
value: exitPIP,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const video2 = createVideoElement();
|
||||||
|
stateManager.setState({
|
||||||
|
mediaLoadedInfo: createMediaLoadedInfo({
|
||||||
|
mediaPlayerController: createMediaPlayerControllerWithPIP(video2),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitPIP).not.toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('native PIP detection', () => {
|
||||||
|
it('updates card when native PIP is entered', () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const { manager, video } = setupWithVideo(api);
|
||||||
|
|
||||||
|
stubPIPElement(video);
|
||||||
|
video.dispatchEvent(new Event('enterpictureinpicture'));
|
||||||
|
|
||||||
|
expect(manager.isInPIP()).toBe(true);
|
||||||
|
expect(api.getCardElementManager().update).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates card when PIP is exited via leavepictureinpicture', () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const { manager, video } = setupWithVideo(api);
|
||||||
|
|
||||||
|
stubPIPElement(video);
|
||||||
|
video.dispatchEvent(new Event('enterpictureinpicture'));
|
||||||
|
expect(manager.isInPIP()).toBe(true);
|
||||||
|
|
||||||
|
stubPIPElement(null);
|
||||||
|
video.dispatchEvent(new Event('leavepictureinpicture'));
|
||||||
|
|
||||||
|
expect(manager.isInPIP()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('togglePIP', () => {
|
||||||
|
it('enters PIP when a video is available', async () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const { manager, video } = setupWithVideo(api);
|
||||||
|
|
||||||
|
await manager.togglePIP();
|
||||||
|
|
||||||
|
expect(video.requestPictureInPicture).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits PIP when currently in PIP', async () => {
|
||||||
|
stubPIPSupported();
|
||||||
|
const api = createCardAPI();
|
||||||
|
const { manager, video } = setupWithVideo(api);
|
||||||
|
|
||||||
|
stubPIPElement(video);
|
||||||
|
Object.defineProperty(document, 'exitPictureInPicture', {
|
||||||
|
value: vi.fn().mockResolvedValue(undefined),
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.togglePIP();
|
||||||
|
|
||||||
|
expect(document.exitPictureInPicture).toBeCalled();
|
||||||
|
expect(video.requestPictureInPicture).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not enter PIP when no video is available', async () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new PIPManager(api);
|
||||||
|
|
||||||
|
await manager.togglePIP();
|
||||||
|
|
||||||
|
expect(api.getCardElementManager().update).not.toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -114,4 +114,10 @@ describe('ImageMediaPlayerController', () => {
|
|||||||
expect(controller.getFullscreenElement()).toBeNull();
|
expect(controller.getFullscreenElement()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return null for getPIPElement', () => {
|
||||||
|
const controller = new ImageMediaPlayerController(createLitElement(), () => null);
|
||||||
|
|
||||||
|
expect(controller.getPIPElement()).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -249,4 +249,14 @@ describe('JSMPEGMediaPlayerController', () => {
|
|||||||
expect(controller.getFullscreenElement()).toBeNull();
|
expect(controller.getFullscreenElement()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return null for getPIPElement', () => {
|
||||||
|
const controller = new JSMPEGMediaPlayerController(
|
||||||
|
createLitElement(),
|
||||||
|
() => mock<JSMpeg.VideoElement>(),
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(controller.getPIPElement()).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -189,4 +189,14 @@ describe('UpdatingImageMediaPlayerController', () => {
|
|||||||
expect(controller.getFullscreenElement()).toBeNull();
|
expect(controller.getFullscreenElement()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return null for getPIPElement', () => {
|
||||||
|
const controller = new UpdatingImageMediaPlayerController(
|
||||||
|
createLitElement(),
|
||||||
|
() => null,
|
||||||
|
() => mock<CachedValueController<string>>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(controller.getPIPElement()).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -270,4 +270,20 @@ describe('VideoMediaPlayerController', () => {
|
|||||||
expect(controller.getFullscreenElement()).toBeNull();
|
expect(controller.getFullscreenElement()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('should get PIP element', () => {
|
||||||
|
it('should return video element when available', () => {
|
||||||
|
const video = mock<HTMLVideoElement>();
|
||||||
|
|
||||||
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||||
|
|
||||||
|
expect(controller.getPIPElement()).toBe(video);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null without video', () => {
|
||||||
|
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
|
||||||
|
|
||||||
|
expect(controller.getPIPElement()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { FoldersManager } from '../../src/card-controller/folders/manager.js';
|
|||||||
import { FolderQuery } from '../../src/card-controller/folders/types';
|
import { FolderQuery } from '../../src/card-controller/folders/types';
|
||||||
import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager.js';
|
import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager.js';
|
||||||
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager.js';
|
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager.js';
|
||||||
|
import { PIPManager } from '../../src/card-controller/pip-manager.js';
|
||||||
import { MicrophoneManager } from '../../src/card-controller/microphone-manager.js';
|
import { MicrophoneManager } from '../../src/card-controller/microphone-manager.js';
|
||||||
import { ViewManager } from '../../src/card-controller/view/view-manager.js';
|
import { ViewManager } from '../../src/card-controller/view/view-manager.js';
|
||||||
import {
|
import {
|
||||||
@@ -1484,6 +1485,75 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('should have pip button', () => {
|
||||||
|
it('when not in PIP mode', () => {
|
||||||
|
const pipManager = mock<PIPManager>();
|
||||||
|
vi.mocked(pipManager.isInPIP).mockReturnValue(false);
|
||||||
|
vi.mocked(pipManager.isAvailable).mockReturnValue(true);
|
||||||
|
|
||||||
|
const buttons = calculateButtons(controller, { pipManager });
|
||||||
|
|
||||||
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
|
icon: 'mdi:picture-in-picture-bottom-right',
|
||||||
|
enabled: false,
|
||||||
|
priority: 50,
|
||||||
|
type: 'custom:advanced-camera-card-menu-icon',
|
||||||
|
title: 'Picture in Picture',
|
||||||
|
tap_action: {
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
advanced_camera_card_action: 'pip',
|
||||||
|
},
|
||||||
|
style: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when in PIP mode', () => {
|
||||||
|
const pipManager = mock<PIPManager>();
|
||||||
|
vi.mocked(pipManager.isInPIP).mockReturnValue(true);
|
||||||
|
vi.mocked(pipManager.isAvailable).mockReturnValue(true);
|
||||||
|
|
||||||
|
const buttons = calculateButtons(controller, { pipManager });
|
||||||
|
|
||||||
|
expect(buttons).toContainEqual({
|
||||||
|
alignment: 'matching',
|
||||||
|
state_color: true,
|
||||||
|
permanent: false,
|
||||||
|
icon: 'mdi:picture-in-picture-bottom-right-outline',
|
||||||
|
enabled: false,
|
||||||
|
priority: 50,
|
||||||
|
type: 'custom:advanced-camera-card-menu-icon',
|
||||||
|
title: 'Picture in Picture',
|
||||||
|
tap_action: {
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
advanced_camera_card_action: 'pip',
|
||||||
|
},
|
||||||
|
style: { color: 'var(--advanced-camera-card-menu-button-active-color)' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when not supported', () => {
|
||||||
|
const pipManager = mock<PIPManager>();
|
||||||
|
vi.mocked(pipManager.isAvailable).mockReturnValue(false);
|
||||||
|
|
||||||
|
const buttons = calculateButtons(controller, { pipManager });
|
||||||
|
|
||||||
|
expect(buttons).not.toContainEqual(
|
||||||
|
expect.objectContaining({ title: 'Picture in Picture' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when no pipManager provided', () => {
|
||||||
|
const buttons = calculateButtons(controller, {});
|
||||||
|
|
||||||
|
expect(buttons).not.toContainEqual(
|
||||||
|
expect.objectContaining({ title: 'Picture in Picture' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('should have expand button', () => {
|
describe('should have expand button', () => {
|
||||||
it('when not expanded', () => {
|
it('when not expanded', () => {
|
||||||
const buttons = calculateButtons(controller, { inExpandedMode: false });
|
const buttons = calculateButtons(controller, { inExpandedMode: false });
|
||||||
|
|||||||
@@ -320,6 +320,13 @@ describe('config defaults', () => {
|
|||||||
priority: 50,
|
priority: 50,
|
||||||
state_color: true,
|
state_color: true,
|
||||||
},
|
},
|
||||||
|
pip: {
|
||||||
|
alignment: 'matching',
|
||||||
|
enabled: false,
|
||||||
|
permanent: false,
|
||||||
|
priority: 50,
|
||||||
|
state_color: true,
|
||||||
|
},
|
||||||
play: {
|
play: {
|
||||||
alignment: 'matching',
|
alignment: 'matching',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { MediaPlayerManager } from '../src/card-controller/media-player-manager'
|
|||||||
import { MessageManager } from '../src/card-controller/message-manager';
|
import { MessageManager } from '../src/card-controller/message-manager';
|
||||||
import { MicrophoneManager } from '../src/card-controller/microphone-manager';
|
import { MicrophoneManager } from '../src/card-controller/microphone-manager';
|
||||||
import { OverlayMessageManager } from '../src/card-controller/overlay-message-manager';
|
import { OverlayMessageManager } from '../src/card-controller/overlay-message-manager';
|
||||||
|
import { PIPManager } from '../src/card-controller/pip-manager';
|
||||||
import { QueryStringManager } from '../src/card-controller/query-string-manager';
|
import { QueryStringManager } from '../src/card-controller/query-string-manager';
|
||||||
import { StatusBarItemManager } from '../src/card-controller/status-bar-item-manager';
|
import { StatusBarItemManager } from '../src/card-controller/status-bar-item-manager';
|
||||||
import { StyleManager } from '../src/card-controller/style-manager';
|
import { StyleManager } from '../src/card-controller/style-manager';
|
||||||
@@ -678,6 +679,7 @@ export const createCardAPI = (): CardController => {
|
|||||||
api.getMessageManager.mockReturnValue(mock<MessageManager>());
|
api.getMessageManager.mockReturnValue(mock<MessageManager>());
|
||||||
api.getMicrophoneManager.mockReturnValue(mock<MicrophoneManager>());
|
api.getMicrophoneManager.mockReturnValue(mock<MicrophoneManager>());
|
||||||
api.getOverlayMessageManager.mockReturnValue(mock<OverlayMessageManager>());
|
api.getOverlayMessageManager.mockReturnValue(mock<OverlayMessageManager>());
|
||||||
|
api.getPIPManager.mockReturnValue(mock<PIPManager>());
|
||||||
api.getQueryStringManager.mockReturnValue(mock<QueryStringManager>());
|
api.getQueryStringManager.mockReturnValue(mock<QueryStringManager>());
|
||||||
api.getStatusBarItemManager.mockReturnValue(mock<StatusBarItemManager>());
|
api.getStatusBarItemManager.mockReturnValue(mock<StatusBarItemManager>());
|
||||||
api.getStyleManager.mockReturnValue(mock<StyleManager>());
|
api.getStyleManager.mockReturnValue(mock<StyleManager>());
|
||||||
|
|||||||
Reference in New Issue
Block a user