feat: Add basic fullscreen support for iPhone (#1857)

- Closes #1444
This commit is contained in:
Dermot Duffy
2025-01-27 07:23:07 -08:00
committed by GitHub
parent d1afbc95b6
commit 3e8e688b36
44 changed files with 1150 additions and 308 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ menu:
| `download` | The `download` menu button: allow direct download of the media being displayed. |
| `expand` | The `expand` menu button: expand the card into a popup/dialog. |
| `frigate` | The `Frigate` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.style` is `hidden` . |
| `fullscreen` | The `fullscreen` menu button: expand the card to consume the fullscreen. |
| `fullscreen` | The `fullscreen` menu button: expand the card to consume the fullscreen. Please note that fullscreen behavior on iPhone is limited, see [troubleshooting](../troubleshooting.md?id=fullscreen-doesn39t-work-on-iphone). |
| `image` | The `image` view menu button: brings the user to the static `image` view. |
| `live` | The `live` view menu button: brings the user to the `live` view. |
| `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. |
+9 -2
View File
@@ -189,8 +189,15 @@ live:
### Fullscreen doesn't work on iPhone
Unfortunately, [iOS does not support the Javascript fullscreen
API](https://caniuse.com/fullscreen). As a result, card-level fullscreen support
for the iPhone is not currently possible.
API](https://caniuse.com/fullscreen) on the iPhone, which severely limits the
fullscreen functionality available. On iPhone, fullscreen is only possible of
the selected video element. As a result, there will be no menu, status bar, grid
support, gallery / timeline support, nor support for non-video based [live
providers](./configuration/cameras/live-provider.md) such as `image` or `jsmpeg`
-- exclusively viewing a selected live video or media video in fullscreen.
The card will only show the fullscreen menu button when fullscreen can usefully
be activated, which means for certain views on the iPhone it will be absent.
### iOS App not updating after card version change
+1 -1
View File
@@ -128,7 +128,7 @@ export class CardElementManager {
);
this._api.getConditionsManager()?.setState({
user_agent: navigator.userAgent,
userAgent: navigator.userAgent,
});
// Manually request query string execute as the card will be
+35 -24
View File
@@ -17,25 +17,25 @@ import {
ViewDisplayMode,
} from '../config/types';
import { localize } from '../localize/localize';
import { FrigateCardError } from '../types';
import { FrigateCardError, MediaLoadedInfo } from '../types';
import { desparsifyArrays } from '../utils/basic';
import { isCompanionApp } from '../utils/companion';
import { CardConditionAPI, KeysState, MicrophoneState } from './types';
interface ConditionState {
view?: string;
fullscreen?: boolean;
expand?: boolean;
export interface ConditionState {
camera?: string;
state?: HassEntities;
media_loaded?: boolean;
displayMode?: ViewDisplayMode;
triggered?: Set<string>;
expand?: boolean;
fullscreen?: boolean;
interaction?: boolean;
microphone?: MicrophoneState;
user?: CurrentUser;
keys?: KeysState;
user_agent?: string;
mediaLoadedInfo?: MediaLoadedInfo | null;
microphone?: MicrophoneState;
state?: HassEntities;
triggered?: Set<string>;
user?: CurrentUser;
userAgent?: string;
view?: string;
}
class OverrideConfigurationError extends FrigateCardError {}
@@ -152,7 +152,10 @@ export interface ConditionsManagerEpoch {
manager: Readonly<ConditionsManager>;
}
export type ConditionsManagerListener = () => void;
export type ConditionsManagerListener = (
newState: ConditionState,
oldState?: ConditionState,
) => void;
export class ConditionsManager {
protected _api: CardConditionAPI;
@@ -166,7 +169,7 @@ export class ConditionsManager {
// the configuration needs to consume it.
protected _hasHAStateConditions = false;
protected _mediaQueries: MediaQueryList[] = [];
protected _mediaQueryTrigger = () => this._triggerChange();
protected _mediaQueryTrigger = () => this._triggerChange(this._state);
constructor(api: CardConditionAPI, listener?: ConditionsManagerListener) {
this._api = api;
@@ -177,6 +180,14 @@ export class ConditionsManager {
];
}
public addListener(listener: ConditionsManagerListener): void {
this._listeners.push(listener);
}
public removeListener(listener: ConditionsManagerListener): void {
this._listeners = this._listeners.filter((l) => l !== listener);
}
public removeConditions(): void {
this._mediaQueries.forEach((mql) =>
mql.removeEventListener('change', this._mediaQueryTrigger),
@@ -233,12 +244,12 @@ export class ConditionsManager {
if (Object.keys(state).every((key) => isEqual(state[key], this._state[key]))) {
return;
}
const oldState = this._state;
this._state = {
...this._state,
...oldState,
...state,
};
this._triggerChange();
this._triggerChange(oldState);
}
public getState(): ConditionState {
@@ -313,8 +324,8 @@ export class ConditionsManager {
return !!state.user && conditionObj.users.includes(state.user.id);
case 'media_loaded':
return (
state.media_loaded !== undefined &&
conditionObj.media_loaded === state.media_loaded
state.mediaLoadedInfo !== undefined &&
conditionObj.media_loaded === !!state.mediaLoadedInfo
);
case 'screen':
return window.matchMedia(conditionObj.media_query).matches;
@@ -352,12 +363,12 @@ export class ConditionsManager {
);
case 'user_agent':
return (
!!state.user_agent &&
(!conditionObj.user_agent || conditionObj.user_agent === state.user_agent) &&
!!state.userAgent &&
(!conditionObj.user_agent || conditionObj.user_agent === state.userAgent) &&
(conditionObj.companion === undefined ||
conditionObj.companion === isCompanionApp(state.user_agent)) &&
conditionObj.companion === isCompanionApp(state.userAgent)) &&
(conditionObj.user_agent_re === undefined ||
new RegExp(conditionObj.user_agent_re).test(state.user_agent))
new RegExp(conditionObj.user_agent_re).test(state.userAgent))
);
}
}
@@ -366,8 +377,8 @@ export class ConditionsManager {
return { manager: this };
}
protected _triggerChange(): void {
protected _triggerChange(oldState?: ConditionState): void {
this._epoch = this._createEpoch();
this._listeners.forEach((listener) => listener());
this._listeners.forEach((listener) => listener(this._state, oldState));
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ import { ConfigManager } from './config/config-manager';
import { DefaultManager } from './default-manager';
import { DownloadManager } from './download-manager';
import { ExpandManager } from './expand-manager';
import { FullscreenManager } from './fullscreen-manager';
import { FullscreenManager } from './fullscreen/fullscreen-manager';
import { HASSManager } from './hass/hass-manager';
import { InitializationManager } from './initialization-manager';
import { InteractionManager } from './interaction-manager';
+1 -1
View File
@@ -23,7 +23,7 @@ export class ExpandManager {
public setExpanded(expanded: boolean): void {
if (expanded && this._api.getFullscreenManager().isInFullscreen()) {
// Fullscreen and expanded mode are mutually exclusive.
this._api.getFullscreenManager().stopFullscreen();
this._api.getFullscreenManager().setFullscreen(false);
}
this._expanded = expanded;
-55
View File
@@ -1,55 +0,0 @@
import screenfull from 'screenfull';
import { CardFullscreenAPI } from './types';
export class FullscreenManager {
protected _api: CardFullscreenAPI;
constructor(api: CardFullscreenAPI) {
this._api = api;
}
public initialize(): void {
this._setConditionState();
}
public connect(): void {
if (screenfull.isEnabled) {
screenfull.on('change', this._fullscreenHandler);
}
}
public disconnect(): void {
if (screenfull.isEnabled) {
screenfull.off('change', this._fullscreenHandler);
}
}
public isInFullscreen(): boolean {
return screenfull.isEnabled && screenfull.isFullscreen;
}
public toggleFullscreen(): void {
screenfull.toggle(this._api.getCardElementManager().getElement());
}
public stopFullscreen(): void {
screenfull.exit();
}
protected _setConditionState(): void {
this._api.getConditionsManager()?.setState({
fullscreen: this.isInFullscreen(),
});
}
protected _fullscreenHandler = (): void => {
this._api.getExpandManager().setExpanded(false);
this._setConditionState();
// Re-render after a change to fullscreen mode to take advantage of
// the expanded screen real-estate (vs staying in aspect-ratio locked
// modes).
this._api.getCardElementManager().update();
};
}
+24
View File
@@ -0,0 +1,24 @@
import screenfull from 'screenfull';
import { WebkitHTMLVideoElement } from '../../types';
import { CardFullscreenAPI } from '../types';
import { ScreenfullFullScreenProvider } from './screenfull';
import { FullscreenHandler, FullscreenProvider } from './types';
import { WebkitFullScreenProvider } from './webkit';
export class FullscreenProviderFactory {
public static create(
api: CardFullscreenAPI,
handler: FullscreenHandler,
): FullscreenProvider | null {
if (screenfull.isEnabled) {
return new ScreenfullFullScreenProvider(api, handler);
}
const video = document.createElement('video') as Partial<WebkitHTMLVideoElement>;
if (!!video.webkitEnterFullscreen) {
return new WebkitFullScreenProvider(api, handler);
}
return null;
}
}
@@ -0,0 +1,67 @@
import { isBeingCasted } from '../../utils/casting';
import { CardFullscreenAPI } from '../types';
import { FullscreenProviderFactory } from './factory';
import { FullscreenProvider } from './types';
export class FullscreenManager {
protected _api: CardFullscreenAPI;
protected _provider: FullscreenProvider | null;
constructor(api: CardFullscreenAPI, provider?: FullscreenProvider) {
this._api = api;
this._provider =
provider ?? FullscreenProviderFactory.create(api, this._fullscreenHandler);
}
public connect(): void {
this._provider?.connect();
}
public disconnect(): void {
this._provider?.disconnect();
}
public setFullscreen(fullscreen: boolean): void {
this._provider?.setFullscreen(fullscreen);
}
public isInFullscreen(): boolean {
return this._provider?.isInFullscreen() ?? false;
}
public isSupported(): boolean {
if (isBeingCasted()) {
return false;
}
return this._provider?.isSupported() ?? false;
}
public initialize(): void {
this._setConditionState();
}
public toggleFullscreen(): void {
if (this._provider?.isInFullscreen()) {
this._provider.setFullscreen(false);
} else {
this._provider?.setFullscreen(true);
}
}
protected _fullscreenHandler = (): void => {
this._api.getExpandManager().setExpanded(false);
this._setConditionState();
// Re-render after a change to fullscreen mode to take advantage of
// the expanded screen real-estate (vs staying in aspect-ratio locked
// modes).
this._api.getCardElementManager().update();
};
protected _setConditionState(): void {
this._api.getConditionsManager()?.setState({
fullscreen: this.isInFullscreen(),
});
}
}
@@ -0,0 +1,12 @@
import { CardFullscreenAPI } from '../types';
import { FullscreenHandler } from './types';
export class FullscreenProviderBase {
protected _api: CardFullscreenAPI;
protected _handler: FullscreenHandler;
constructor(api: CardFullscreenAPI, handler: FullscreenHandler) {
this._api = api;
this._handler = handler;
}
}
@@ -0,0 +1,40 @@
import screenfull from 'screenfull';
import { FullscreenProviderBase } from '../provider';
import { FullscreenProvider } from '../types';
export class ScreenfullFullScreenProvider
extends FullscreenProviderBase
implements FullscreenProvider
{
public connect(): void {
if (screenfull.isEnabled) {
screenfull.on('change', this._handler);
}
}
public disconnect(): void {
if (screenfull.isEnabled) {
screenfull.off('change', this._handler);
}
}
public isInFullscreen(): boolean {
return screenfull.isEnabled && screenfull.isFullscreen;
}
public isSupported(): boolean {
return screenfull.isEnabled;
}
public setFullscreen(fullscreen: boolean): void {
if (!this.isSupported()) {
return;
}
if (fullscreen) {
screenfull.request(this._api.getCardElementManager().getElement());
} else {
screenfull.exit();
}
}
}
+11
View File
@@ -0,0 +1,11 @@
export type FullscreenHandler = () => void;
export interface FullscreenProvider {
connect(): void;
disconnect(): void;
isInFullscreen(): boolean;
isSupported(): boolean;
setFullscreen(fullscreen: boolean): void;
}
@@ -0,0 +1,92 @@
import { WebkitHTMLVideoElement } from '../../../types';
import { Timer } from '../../../utils/timer';
import { ConditionState } from '../../conditions-manager';
import { FullscreenProviderBase } from '../provider';
import { FullscreenProvider } from '../types';
const WEBKIT_PLAY_SECONDS = 0.5;
/**
* Fullscreen implementation for webkit based browsers that do not support the
* standard Fullscreen API (expected to be exclusively iOS on an iPhone).
* See: https://github.com/dermotduffy/frigate-hass-card/issues/1444
*/
export class WebkitFullScreenProvider
extends FullscreenProviderBase
implements FullscreenProvider
{
protected _playTimer = new Timer();
public connect(): void {
this._api.getConditionsManager().addListener(this._conditionChangeHandler);
}
public disconnect(): void {
this._api.getConditionsManager().removeListener(this._conditionChangeHandler);
}
protected _conditionChangeHandler = (
newState: ConditionState,
oldState?: ConditionState,
): void => {
if (
newState.mediaLoadedInfo?.player?.getFullscreenElement() !==
oldState?.mediaLoadedInfo?.player?.getFullscreenElement()
) {
const oldElement = oldState?.mediaLoadedInfo?.player?.getFullscreenElement();
oldElement?.removeEventListener('webkitbeginfullscreen', this._handler);
oldElement?.removeEventListener('webkitendfullscreen', this._endHandler);
const newElement = newState.mediaLoadedInfo?.player?.getFullscreenElement();
newElement?.addEventListener('webkitbeginfullscreen', this._handler);
newElement?.addEventListener('webkitendfullscreen', this._endHandler);
}
};
protected _getVideoElement():
| (HTMLVideoElement & Partial<WebkitHTMLVideoElement>)
| null {
const element = this._api
.getMediaLoadedInfoManager()
.get()
?.player?.getFullscreenElement();
return element instanceof HTMLVideoElement ? element : null;
}
public isInFullscreen(): boolean {
return !!this._getVideoElement()?.webkitDisplayingFullscreen;
}
public isSupported(): boolean {
return !!this._getVideoElement()?.webkitSupportsFullscreen;
}
public setFullscreen(fullscreen: boolean): void {
if (!this.isSupported()) {
return;
}
const video = this._getVideoElement();
if (fullscreen) {
video?.webkitEnterFullscreen?.();
} else {
video?.webkitExitFullscreen?.();
}
}
protected _endHandler = (): void => {
this._handler();
// Webkit on iPhone stops the video when exiting fullscreen (why!). This
// hack workaround has the disadvantage that there's no way to tell if the
// video was legitimately paused before they user exited fullscreen (as the
// common path is for exit fullscreen to not involve
// setFullscreen(...) being called at all, but rather the user simply
// clicking the 'X' which then fires this event). That's probably the rare
// case though.
this._playTimer.start(WEBKIT_PLAY_SECONDS, () => {
this._getVideoElement()?.play();
});
};
}
+7 -7
View File
@@ -16,21 +16,21 @@ export class MediaLoadedInfoManager {
this.clear();
}
public set(mediaInfo: MediaLoadedInfo): void {
if (!isValidMediaLoadedInfo(mediaInfo)) {
public set(mediaLoadedInfo: MediaLoadedInfo): void {
if (!isValidMediaLoadedInfo(mediaLoadedInfo)) {
return;
}
log(
this._api.getConfigManager().getCardWideConfig(),
`Frigate Card media load: `,
mediaInfo,
mediaLoadedInfo,
);
this._current = mediaInfo;
this._lastKnown = mediaInfo;
this._current = mediaLoadedInfo;
this._lastKnown = mediaLoadedInfo;
this._api.getConditionsManager().setState({ media_loaded: true });
this._api.getConditionsManager().setState({ mediaLoadedInfo: mediaLoadedInfo });
// Fresh media information may change how the card is rendered.
this._api.getStyleManager().setExpandedMode();
@@ -47,7 +47,7 @@ export class MediaLoadedInfoManager {
public clear(): void {
this._current = null;
this._api.getConditionsManager().setState({ media_loaded: false });
this._api.getConditionsManager().setState({ mediaLoadedInfo: null });
}
public has(): boolean {
+3 -1
View File
@@ -11,7 +11,7 @@ import type { ConfigManager } from './config/config-manager';
import type { DefaultManager } from './default-manager';
import type { DownloadManager } from './download-manager';
import type { ExpandManager } from './expand-manager';
import type { FullscreenManager } from './fullscreen-manager';
import type { FullscreenManager } from './fullscreen/fullscreen-manager';
import type { HASSManager } from './hass/hass-manager';
import type { InitializationManager } from './initialization-manager';
import type { InteractionManager } from './interaction-manager';
@@ -150,6 +150,7 @@ export interface CardFullscreenAPI {
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getExpandManager(): ExpandManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMediaPlayerManager(): MediaPlayerManager;
}
@@ -205,6 +206,7 @@ export interface CardMediaLoadedAPI {
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager;
getFullscreenManager(): FullscreenManager;
getStyleManager(): StyleManager;
}
+1 -1
View File
@@ -270,7 +270,7 @@ class FrigateCard extends LitElement {
this._controller.getCameraManager(),
{
inExpandedMode: this._controller.getExpandManager().isExpanded(),
inFullscreenMode: this._controller.getFullscreenManager().isInFullscreen(),
fullscreenManager: this._controller.getFullscreenManager(),
currentMediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(),
showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(),
mediaPlayerController: this._controller.getMediaPlayerManager(),
+9 -7
View File
@@ -1,6 +1,7 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { StyleInfo } from 'lit/directives/style-map';
import { CameraManager } from '../camera-manager/manager';
import { FullscreenManager } from '../card-controller/fullscreen/fullscreen-manager';
import { MediaPlayerManager } from '../card-controller/media-player-manager';
import { MicrophoneManager } from '../card-controller/microphone-manager';
import { ViewManager } from '../card-controller/view/view-manager';
@@ -32,7 +33,7 @@ import { getCameraIDsForViewName } from '../view/view-to-cameras';
export interface MenuButtonControllerOptions {
currentMediaLoadedInfo?: MediaLoadedInfo | null;
showCameraUIButton?: boolean;
inFullscreenMode?: boolean;
fullscreenManager?: FullscreenManager | null;
inExpandedMode?: boolean;
microphoneManager?: MicrophoneManager | null;
mediaPlayerController?: MediaPlayerManager | null;
@@ -84,7 +85,7 @@ export class MenuButtonController {
options?.currentMediaLoadedInfo,
),
this._getExpandButton(config, options?.inExpandedMode),
this._getFullscreenButton(config, options?.inFullscreenMode),
this._getFullscreenButton(config, options?.fullscreenManager),
this._getCastButton(
hass,
config,
@@ -420,16 +421,17 @@ export class MenuButtonController {
protected _getFullscreenButton(
config: FrigateCardConfig,
inFullscreenMode?: boolean,
fullscreenManager?: FullscreenManager | null,
): MenuItem | null {
return !isBeingCasted()
const inFullscreen = fullscreenManager?.isInFullscreen();
return fullscreenManager?.isSupported()
? {
icon: inFullscreenMode ? 'mdi:fullscreen-exit' : 'mdi:fullscreen',
icon: inFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen',
...config.menu.buttons.fullscreen,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.fullscreen'),
tap_action: createGeneralAction('fullscreen') as FrigateCardCustomAction,
style: inFullscreenMode ? this._getEmphasizedStyle() : {},
style: inFullscreen ? this._getEmphasizedStyle() : {},
}
: null;
}
@@ -694,7 +696,7 @@ export class MenuButtonController {
(frigateCardAction.frigate_card_action === 'default' &&
options?.view?.is(config.view.default)) ||
(frigateCardAction.frigate_card_action === 'fullscreen' &&
!!options?.inFullscreenMode) ||
!!options?.fullscreenManager?.isInFullscreen()) ||
(frigateCardAction.frigate_card_action === 'camera_select' &&
options?.view?.camera === frigateCardAction.camera)
) {
+10 -1
View File
@@ -19,7 +19,12 @@ import { CameraConfig, ImageMode, ImageViewConfig } from '../config/types.js';
import defaultImage from '../images/frigate-bird-in-sky.jpg';
import { localize } from '../localize/localize.js';
import imageStyle from '../scss/image.scss';
import { FrigateCardMediaPlayer, MediaLoadedInfo, Message } from '../types.js';
import {
FrigateCardMediaPlayer,
FullscreenElement,
MediaLoadedInfo,
Message,
} from '../types.js';
import { contentsChanged } from '../utils/basic.js';
import { isHassDifferent } from '../utils/ha/index.js';
import {
@@ -123,6 +128,10 @@ export class FrigateCardImageBase extends LitElement implements FrigateCardMedia
return this._cachedValueController?.value ?? null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._refImage.value ?? null;
}
/**
* Determine whether the element should be updated.
* @param changedProps The changed properties if any.
+5 -1
View File
@@ -17,7 +17,7 @@ import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-vie
import { CameraConfig, ImageViewConfig } from '../config/types';
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const';
import basicBlockStyle from '../scss/basic-block.scss';
import { FrigateCardMediaPlayer } from '../types.js';
import { FrigateCardMediaPlayer, FullscreenElement } from '../types.js';
import { aspectRatioToString } from '../utils/basic';
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
import './image-base';
@@ -79,6 +79,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
return (await this._refImage.value?.getScreenshotURL()) ?? null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._refImage.value?.getFullscreenElement() ?? null;
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('cameraConfig') || changedProps.has('imageConfig')) {
if (
+9 -1
View File
@@ -24,7 +24,11 @@ import {
import { STREAM_TROUBLESHOOTING_URL } from '../../const.js';
import { localize } from '../../localize/localize.js';
import liveProviderStyle from '../../scss/live-provider.scss';
import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js';
import {
ExtendedHomeAssistant,
FrigateCardMediaPlayer,
FullscreenElement,
} from '../../types.js';
import { aspectRatioToString } from '../../utils/basic.js';
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
@@ -144,6 +148,10 @@ export class FrigateCardLiveProvider
return (await this._refProvider.value?.getScreenshotURL()) ?? null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._refProvider.value?.getFullscreenElement() ?? null;
}
/**
* Get the fully resolved live provider.
* @returns A live provider (that is not 'auto').
@@ -16,6 +16,7 @@ import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
import {
ExtendedHomeAssistant,
FrigateCardMediaPlayer,
FullscreenElement,
Message,
} from '../../../../types.js';
import { convertEndpointAddressToSignedWebsocket } from '../../../../utils/endpoint.js';
@@ -102,6 +103,10 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
return this._player?.video ? screenshotMedia(this._player.video) : null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._player?.video ?? null;
}
disconnectedCallback(): void {
this._player = undefined;
this._message = null;
@@ -151,7 +156,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
this._player.microphoneStream = this.microphoneState?.stream ?? null;
this._player.src = address;
this._player.visibilityCheck = false;
this._player.controls = this.controls;
this._player.setControls(this.controls);
if (this.cameraConfig?.go2rtc?.modes && this.cameraConfig.go2rtc.modes.length) {
this._player.mode = this.cameraConfig.go2rtc.modes.join(',');
@@ -170,7 +175,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
}
if (changedProps.has('controls') && this._player) {
this._player.controls = this.controls;
this._player.setControls(this.controls);
}
if (
+2 -1
View File
@@ -29,8 +29,9 @@ export class VideoRTC extends HTMLElement {
onmessage: Record<string, (msg: { type: string; value: string }) => void>;
// Custom methods/members.
controls: boolean;
containingPlayer: FrigateCardMediaPlayer | null;
microphoneStream: MediaStream | null;
reconnect();
setControls(controls: boolean): void;
}
@@ -168,7 +168,7 @@ export class VideoRTC extends HTMLElement {
this.containingPlayer = null;
/**
* Whether to show or hide video controls.
* Whether to show or hide video controls for videos created in future.
* @type {boolean}}
*/
this.controls = true;
@@ -191,6 +191,15 @@ export class VideoRTC extends HTMLElement {
}
}
setControls(controls) {
this.controls = controls;
// If the video already exists the controls won't be changed by default.
if (this.video) {
setControlsOnVideo(this.video, controls);
}
}
/**
* Set video source (WebSocket URL). Support relative path.
* @param {string|URL} value
+5 -1
View File
@@ -7,7 +7,7 @@ import '../../../patches/ha-camera-stream';
import '../../../patches/ha-hls-player.js';
import '../../../patches/ha-web-rtc-player.js';
import liveHAStyle from '../../../scss/live-ha.scss';
import { FrigateCardMediaPlayer } from '../../../types.js';
import { FrigateCardMediaPlayer, FullscreenElement } from '../../../types.js';
@customElement('frigate-card-live-ha')
export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPlayer {
@@ -58,6 +58,10 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
return (await this._playerRef.value?.getScreenshotURL()) ?? null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._playerRef.value?.getFullscreenElement() ?? null;
}
protected render(): TemplateResult | void {
if (!this.hass) {
return;
+5 -1
View File
@@ -4,7 +4,7 @@ import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CameraConfig } from '../../../config/types';
import basicBlockStyle from '../../../scss/basic-block.scss';
import { FrigateCardMediaPlayer } from '../../../types.js';
import { FrigateCardMediaPlayer, FullscreenElement } from '../../../types.js';
import '../../image-base.js';
@customElement('frigate-card-live-image')
@@ -53,6 +53,10 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
return (await this._refImage.value?.getScreenshotURL()) ?? null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._refImage.value?.getFullscreenElement() ?? null;
}
protected render(): TemplateResult | void {
if (!this.hass || !this.cameraConfig) {
return;
+5
View File
@@ -17,6 +17,7 @@ import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss';
import {
ExtendedHomeAssistant,
FrigateCardMediaPlayer,
FullscreenElement,
Message,
} from '../../../types.js';
import { convertEndpointAddressToSignedWebsocket } from '../../../utils/endpoint.js';
@@ -101,6 +102,10 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
return this._jsmpegCanvasElement?.toDataURL('image/jpeg') ?? null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._jsmpegCanvasElement ?? null;
}
protected willUpdate(changedProperties: PropertyValues): void {
if (
['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop))
+21 -12
View File
@@ -15,7 +15,12 @@ import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get
import { CameraConfig, CardWideConfig } from '../../../config/types.js';
import { localize } from '../../../localize/localize.js';
import liveWebRTCCardStyle from '../../../scss/live-webrtc-card.scss';
import { FrigateCardError, FrigateCardMediaPlayer, Message } from '../../../types.js';
import {
FrigateCardError,
FrigateCardMediaPlayer,
FullscreenElement,
Message,
} from '../../../types.js';
import { mayHaveAudio } from '../../../utils/audio.js';
import {
dispatchMediaLoadedEvent,
@@ -64,54 +69,58 @@ export class FrigateCardLiveWebRTCCard
protected _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]);
public async play(): Promise<void> {
return this._getPlayer()?.play();
return this._getVideo()?.play();
}
public async pause(): Promise<void> {
this._getPlayer()?.pause();
this._getVideo()?.pause();
}
public async mute(): Promise<void> {
const player = this._getPlayer();
const player = this._getVideo();
if (player) {
player.muted = true;
}
}
public async unmute(): Promise<void> {
const player = this._getPlayer();
const player = this._getVideo();
if (player) {
player.muted = false;
}
}
public isMuted(): boolean {
return this._getPlayer()?.muted ?? true;
return this._getVideo()?.muted ?? true;
}
public async seek(seconds: number): Promise<void> {
const player = this._getPlayer();
const player = this._getVideo();
if (player) {
player.currentTime = seconds;
}
}
public async setControls(controls?: boolean): Promise<void> {
const player = this._getPlayer();
const player = this._getVideo();
if (player) {
setControlsOnVideo(player, controls ?? this.controls);
}
}
public isPaused(): boolean {
return this._getPlayer()?.paused ?? true;
return this._getVideo()?.paused ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
const video = this._getPlayer();
const video = this._getVideo();
return video ? screenshotMedia(video) : null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._getVideo();
}
connectedCallback(): void {
super.connectedCallback();
@@ -141,7 +150,7 @@ export class FrigateCardLiveWebRTCCard
* Get the underlying video player.
* @returns The player or `null` if not found.
*/
protected _getPlayer(): HTMLVideoElement | null {
protected _getVideo(): HTMLVideoElement | null {
return this._getVideoRTC()?.video ?? null;
}
@@ -236,7 +245,7 @@ export class FrigateCardLiveWebRTCCard
// media load event.
this.updateComplete.then(() => {
const videoRTC = this._getVideoRTC();
const video = this._getPlayer();
const video = this._getVideo();
if (video) {
setControlsOnVideo(video, this.controls);
video.onloadeddata = () => {
+10
View File
@@ -19,6 +19,7 @@ import viewerProviderStyle from '../../scss/viewer-provider.scss';
import {
ExtendedHomeAssistant,
FrigateCardMediaPlayer,
FullscreenElement,
ResolvedMedia,
} from '../../types.js';
import { mayHaveAudio } from '../../utils/audio.js';
@@ -169,6 +170,15 @@ export class FrigateCardViewerProvider
return null;
}
public getFullscreenElement(): FullscreenElement | null {
if (this._refFrigateCardMediaPlayer.value) {
return this._refFrigateCardMediaPlayer.value.getFullscreenElement();
} else if (this._refVideoProvider.value) {
return this._refVideoProvider.value;
}
return null;
}
protected async _switchToRelatedClipView(): Promise<void> {
const view = this.viewManagerEpoch?.manager.getView();
if (
+6 -2
View File
@@ -12,14 +12,14 @@
import { css, CSSResultGroup, html, nothing, PropertyValues, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { query } from 'lit/decorators/query.js';
import { FrigateCardMediaPlayer, MediaLoadedInfo } from '../types.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
import { FrigateCardMediaPlayer, FullscreenElement, MediaLoadedInfo } from '../types.js';
import {
createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent,
} from '../utils/media-info.js';
import './ha-hls-player';
import './ha-web-rtc-player';
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
customElements.whenDefined('ha-camera-stream').then(() => {
// ========================================================================================
@@ -93,6 +93,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
return this._player ? await this._player.getScreenshotURL() : null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._player?.getFullscreenElement() ?? null;
}
protected _storeMediaLoadedInfoHandler(
stream: StreamType,
ev: CustomEvent<MediaLoadedInfo>,
+5 -1
View File
@@ -15,7 +15,7 @@ import { query } from 'lit/decorators/query.js';
import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js';
import { renderMessage } from '../components/message.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
import { FrigateCardMediaPlayer } from '../types.js';
import { FrigateCardMediaPlayer, FullscreenElement } from '../types.js';
import { mayHaveAudio } from '../utils/audio.js';
import { errorToConsole } from '../utils/basic.js';
import {
@@ -91,6 +91,10 @@ customElements.whenDefined('ha-hls-player').then(() => {
return this._video ? screenshotMedia(this._video) : null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._video;
}
// =====================================================================================
// Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
+5 -1
View File
@@ -16,7 +16,7 @@ import { ifDefined } from 'lit/directives/if-defined.js';
import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js';
import { renderMessage } from '../components/message.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
import { FrigateCardMediaPlayer } from '../types.js';
import { FrigateCardMediaPlayer, FullscreenElement } from '../types.js';
import { mayHaveAudio } from '../utils/audio.js';
import {
dispatchMediaLoadedEvent,
@@ -90,6 +90,10 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
return this._video ? screenshotMedia(this._video) : null;
}
public getFullscreenElement(): FullscreenElement | null {
return this._video ?? null;
}
// =====================================================================================
// Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
+10
View File
@@ -54,6 +54,15 @@ export interface Message {
troubleshootingURL?: string;
}
export type WebkitHTMLVideoElement = HTMLVideoElement & {
webkitDisplayingFullscreen: boolean;
webkitSupportsFullscreen: boolean;
webkitEnterFullscreen: () => void;
webkitExitFullscreen: () => void;
};
export type FullscreenElement = HTMLElement;
export interface FrigateCardMediaPlayer {
play(): Promise<void>;
pause(): Promise<void>;
@@ -65,6 +74,7 @@ export interface FrigateCardMediaPlayer {
// If no value for controls if specified, the player should use the default.
setControls(controls?: boolean): Promise<void>;
isPaused(): boolean;
getFullscreenElement(): FullscreenElement | null;
}
export type LovelaceCardWithEditor = LovelaceCard & {
+1 -1
View File
@@ -36,7 +36,7 @@ export const getReleaseVersion = (): string => {
return releaseVersion;
};
export interface Diagnostics {
interface Diagnostics {
card_version: string;
browser: string;
date: Date;
@@ -6,11 +6,13 @@ import {
evaluateConditionViaEvent,
getOverriddenConfig,
} from '../../src/card-controller/conditions-manager';
import { MicrophoneState } from '../../src/card-controller/types';
import { FrigateCardCondition } from '../../src/config/types';
import {
createCardAPI,
createCondition,
createConfig,
createMediaLoadedInfo,
createStateEntity,
createUser,
} from '../test-utils';
@@ -780,9 +782,9 @@ describe('ConditionsManager', () => {
const manager = new ConditionsManager(createCardAPI());
const conditions = [{ condition: 'media_loaded' as const, media_loaded: true }];
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({ media_loaded: true });
manager.setState({ mediaLoadedInfo: createMediaLoadedInfo() });
expect(manager.evaluateConditions(conditions)).toBeTruthy();
manager.setState({ media_loaded: false });
manager.setState({ mediaLoadedInfo: null });
expect(manager.evaluateConditions(conditions)).toBeFalsy();
});
@@ -898,17 +900,27 @@ describe('ConditionsManager', () => {
});
describe('with microphone condition', () => {
const createMicrophoneState = (
state: Partial<MicrophoneState>,
): MicrophoneState => {
return {
connected: false,
muted: false,
forbidden: false,
...state,
};
};
it('empty', () => {
const manager = new ConditionsManager(createCardAPI());
const conditions = [{ condition: 'microphone' as const }];
expect(manager.evaluateConditions(conditions)).toBeTruthy();
manager.setState({ microphone: { connected: true } });
manager.setState({ microphone: createMicrophoneState({ connected: true }) });
expect(manager.evaluateConditions(conditions)).toBeTruthy();
manager.setState({ microphone: { connected: false } });
manager.setState({ microphone: createMicrophoneState({ connected: false }) });
expect(manager.evaluateConditions(conditions)).toBeTruthy();
manager.setState({ microphone: { muted: true } });
manager.setState({ microphone: createMicrophoneState({ muted: true }) });
expect(manager.evaluateConditions(conditions)).toBeTruthy();
manager.setState({ microphone: { muted: false } });
manager.setState({ microphone: createMicrophoneState({ muted: false }) });
expect(manager.evaluateConditions(conditions)).toBeTruthy();
});
@@ -916,9 +928,9 @@ describe('ConditionsManager', () => {
const manager = new ConditionsManager(createCardAPI());
const conditions = [{ condition: 'microphone' as const, connected: true }];
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({ microphone: { connected: true } });
manager.setState({ microphone: createMicrophoneState({ connected: true }) });
expect(manager.evaluateConditions(conditions)).toBeTruthy();
manager.setState({ microphone: { connected: false } });
manager.setState({ microphone: createMicrophoneState({ connected: false }) });
expect(manager.evaluateConditions(conditions)).toBeFalsy();
});
@@ -926,9 +938,9 @@ describe('ConditionsManager', () => {
const manager = new ConditionsManager(createCardAPI());
const conditions = [{ condition: 'microphone' as const, connected: false }];
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({ microphone: { connected: true } });
manager.setState({ microphone: createMicrophoneState({ connected: true }) });
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({ microphone: { connected: false } });
manager.setState({ microphone: createMicrophoneState({ connected: false }) });
expect(manager.evaluateConditions(conditions)).toBeTruthy();
});
@@ -936,9 +948,9 @@ describe('ConditionsManager', () => {
const manager = new ConditionsManager(createCardAPI());
const conditions = [{ condition: 'microphone' as const, muted: true }];
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({ microphone: { muted: true } });
manager.setState({ microphone: createMicrophoneState({ muted: true }) });
expect(manager.evaluateConditions(conditions)).toBeTruthy();
manager.setState({ microphone: { muted: false } });
manager.setState({ microphone: createMicrophoneState({ muted: false }) });
expect(manager.evaluateConditions(conditions)).toBeFalsy();
});
@@ -946,9 +958,9 @@ describe('ConditionsManager', () => {
const manager = new ConditionsManager(createCardAPI());
const conditions = [{ condition: 'microphone' as const, muted: false }];
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({ microphone: { muted: true } });
manager.setState({ microphone: createMicrophoneState({ muted: true }) });
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({ microphone: { muted: false } });
manager.setState({ microphone: createMicrophoneState({ muted: false }) });
expect(manager.evaluateConditions(conditions)).toBeTruthy();
});
@@ -958,13 +970,17 @@ describe('ConditionsManager', () => {
{ condition: 'microphone' as const, muted: false, connected: true },
];
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({ microphone: { muted: true } });
manager.setState({ microphone: createMicrophoneState({ muted: true }) });
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({ microphone: { muted: false } });
manager.setState({ microphone: createMicrophoneState({ muted: false }) });
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({ microphone: { connected: false, muted: false } });
manager.setState({
microphone: createMicrophoneState({ connected: false, muted: false }),
});
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({ microphone: { connected: true, muted: false } });
manager.setState({
microphone: createMicrophoneState({ connected: true, muted: false }),
});
expect(manager.evaluateConditions(conditions)).toBeTruthy();
});
});
@@ -1033,11 +1049,11 @@ describe('ConditionsManager', () => {
const conditions = [{ condition: 'user_agent' as const, user_agent: userAgent }];
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({
user_agent: userAgent,
userAgent: userAgent,
});
expect(manager.evaluateConditions(conditions)).toBeTruthy();
manager.setState({
user_agent: 'Something else',
userAgent: 'Something else',
});
expect(manager.evaluateConditions(conditions)).toBeFalsy();
});
@@ -1049,11 +1065,11 @@ describe('ConditionsManager', () => {
];
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({
user_agent: userAgent,
userAgent: userAgent,
});
expect(manager.evaluateConditions(conditions)).toBeTruthy();
manager.setState({
user_agent: 'Something else',
userAgent: 'Something else',
});
expect(manager.evaluateConditions(conditions)).toBeFalsy();
});
@@ -1063,11 +1079,11 @@ describe('ConditionsManager', () => {
const conditions = [{ condition: 'user_agent' as const, companion: true }];
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({
user_agent: 'Home Assistant/',
userAgent: 'Home Assistant/',
});
expect(manager.evaluateConditions(conditions)).toBeTruthy();
manager.setState({
user_agent: userAgent,
userAgent: userAgent,
});
expect(manager.evaluateConditions(conditions)).toBeFalsy();
});
@@ -1084,14 +1100,37 @@ describe('ConditionsManager', () => {
];
expect(manager.evaluateConditions(conditions)).toBeFalsy();
manager.setState({
user_agent: 'Home Assistant/',
userAgent: 'Home Assistant/',
});
expect(manager.evaluateConditions(conditions)).toBeTruthy();
manager.setState({
user_agent: 'Something else',
userAgent: 'Something else',
});
expect(manager.evaluateConditions(conditions)).toBeFalsy();
});
});
});
it('should add listener', () => {
const listener = vi.fn();
const manager = new ConditionsManager(createCardAPI());
manager.addListener(listener);
const state = { fullscreen: true };
manager.setState(state);
expect(listener).toBeCalledWith(state, {});
});
it('should remove listener', () => {
const listener = vi.fn();
const manager = new ConditionsManager(createCardAPI());
manager.addListener(listener);
manager.removeListener(listener);
const state = { fullscreen: true };
manager.setState(state);
expect(listener).not.toBeCalled();
});
});
+2 -2
View File
@@ -13,7 +13,7 @@ import { CardController } from '../../src/card-controller/controller';
import { DefaultManager } from '../../src/card-controller/default-manager';
import { DownloadManager } from '../../src/card-controller/download-manager';
import { ExpandManager } from '../../src/card-controller/expand-manager';
import { FullscreenManager } from '../../src/card-controller/fullscreen-manager';
import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager';
import { HASSManager } from '../../src/card-controller/hass/hass-manager';
import { InitializationManager } from '../../src/card-controller/initialization-manager';
import { InteractionManager } from '../../src/card-controller/interaction-manager';
@@ -42,7 +42,7 @@ vi.mock('../../src/card-controller/config/config-manager');
vi.mock('../../src/card-controller/default-manager');
vi.mock('../../src/card-controller/download-manager');
vi.mock('../../src/card-controller/expand-manager');
vi.mock('../../src/card-controller/fullscreen-manager');
vi.mock('../../src/card-controller/fullscreen/fullscreen-manager');
vi.mock('../../src/card-controller/hass/hass-manager');
vi.mock('../../src/card-controller/initialization-manager');
vi.mock('../../src/card-controller/interaction-manager');
+2 -2
View File
@@ -25,7 +25,7 @@ describe('ExpandManager', () => {
manager.setExpanded(true);
expect(manager.isExpanded()).toBeTruthy();
expect(api.getFullscreenManager().stopFullscreen).toBeCalled();
expect(api.getFullscreenManager().setFullscreen).toBeCalledWith(false);
expect(api.getConditionsManager().setState).toBeCalledWith({ expand: true });
expect(api.getCardElementManager().update).toBeCalled();
});
@@ -37,7 +37,7 @@ describe('ExpandManager', () => {
manager.setExpanded(true);
expect(api.getFullscreenManager().stopFullscreen).not.toBeCalled();
expect(api.getFullscreenManager().setFullscreen).not.toBeCalled();
});
it('should toggle expanded', () => {
@@ -1,138 +0,0 @@
import screenfull from 'screenfull';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FullscreenManager } from '../../src/card-controller/fullscreen-manager';
import { createCardAPI } from '../test-utils';
vi.mock('screenfull', () => ({
default: {
exit: vi.fn(),
toggle: vi.fn(),
off: vi.fn(),
on: vi.fn(),
},
}));
const setScreenfulEnabled = (enabled: boolean): void => {
Object.defineProperty(screenfull, 'isEnabled', { value: enabled, writable: true });
};
const setScreenfulFullscreen = (fullscreen: boolean): void => {
Object.defineProperty(screenfull, 'isFullscreen', {
value: fullscreen,
writable: true,
});
};
// @vitest-environment jsdom
describe('FullscreenManager', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should initialize', () => {
const api = createCardAPI();
const manager = new FullscreenManager(api);
setScreenfulEnabled(true);
setScreenfulFullscreen(false);
manager.initialize();
expect(api.getConditionsManager().setState).toBeCalledWith({ fullscreen: false });
});
it('should correctly determine whether in fullscreen', () => {
const manager = new FullscreenManager(createCardAPI());
setScreenfulEnabled(true);
setScreenfulFullscreen(true);
expect(manager.isInFullscreen()).toBeTruthy();
setScreenfulFullscreen(false);
expect(manager.isInFullscreen()).toBeFalsy();
});
it('should toggle fullscreen', () => {
const toggle = vi.mocked(screenfull.toggle);
const element = document.createElement('div');
const api = createCardAPI();
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
const manager = new FullscreenManager(api);
manager.toggleFullscreen();
expect(toggle).toBeCalledWith(element);
});
it('should stop fullscreen', () => {
const manager = new FullscreenManager(createCardAPI());
const exit = vi.mocked(screenfull.exit);
manager.stopFullscreen();
expect(exit).toBeCalled();
});
it('should disconnect', () => {
const manager = new FullscreenManager(createCardAPI());
const off = vi.mocked(screenfull.off);
setScreenfulEnabled(true);
manager.disconnect();
expect(off).toBeCalledWith('change', expect.anything());
});
it('should not disconnect when screenfull disabled', () => {
const manager = new FullscreenManager(createCardAPI());
const off = vi.mocked(screenfull.off);
setScreenfulEnabled(false);
manager.disconnect();
expect(off).not.toBeCalled();
});
it('should connect', () => {
const manager = new FullscreenManager(createCardAPI());
const on = vi.mocked(screenfull.on);
setScreenfulEnabled(true);
manager.connect();
expect(on).toBeCalledWith('change', expect.anything());
});
it('should not connect when screenfull disabled', () => {
const manager = new FullscreenManager(createCardAPI());
const on = vi.mocked(screenfull.on);
setScreenfulEnabled(false);
manager.connect();
expect(on).not.toBeCalled();
});
it('should make correct api calls on fullscreen change', () => {
const api = createCardAPI();
const manager = new FullscreenManager(api);
const on = vi.mocked(screenfull.on);
setScreenfulEnabled(true);
setScreenfulFullscreen(true);
manager.connect();
expect(on).toBeCalled();
on.mock.calls[0][1](new Event('fullscreen'));
expect(api.getExpandManager().setExpanded).toBeCalledWith(false);
expect(api.getConditionsManager().setState).toBeCalledWith({
fullscreen: true,
});
expect(api.getCardElementManager().update).toBeCalled();
});
});
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { FullscreenProviderFactory } from '../../../src/card-controller/fullscreen/factory';
import { ScreenfullFullScreenProvider } from '../../../src/card-controller/fullscreen/screenfull';
import { WebkitFullScreenProvider } from '../../../src/card-controller/fullscreen/webkit';
import { WebkitHTMLVideoElement } from '../../../src/types';
import { createCardAPI, setScreenfulEnabled } from '../../test-utils';
// @vitest-environment jsdom
describe('FullscreenProviderFactory', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('should return screenful when enabled', () => {
setScreenfulEnabled(true);
expect(FullscreenProviderFactory.create(createCardAPI(), vi.fn())).toBeInstanceOf(
ScreenfullFullScreenProvider,
);
});
it('should return webkit when enabled', () => {
setScreenfulEnabled(false);
const element = document.createElement('video') as HTMLVideoElement &
Partial<WebkitHTMLVideoElement>;
element['webkitEnterFullscreen'] = vi.fn();
const stubDocument = mock<Document>();
stubDocument.createElement.mockReturnValue(element);
vi.stubGlobal('document', stubDocument);
expect(FullscreenProviderFactory.create(createCardAPI(), vi.fn())).toBeInstanceOf(
WebkitFullScreenProvider,
);
});
it('should return null without any provider', () => {
setScreenfulEnabled(false);
const element = document.createElement('video') as HTMLVideoElement &
Partial<WebkitHTMLVideoElement>;
element['webkitEnterFullscreen'] = undefined;
const stubDocument = mock<Document>();
stubDocument.createElement.mockReturnValue(element);
vi.stubGlobal('document', stubDocument);
expect(FullscreenProviderFactory.create(createCardAPI(), vi.fn())).toBeNull();
});
});
@@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { FullscreenProviderFactory } from '../../../src/card-controller/fullscreen/factory';
import { FullscreenManager } from '../../../src/card-controller/fullscreen/fullscreen-manager';
import { FullscreenProvider } from '../../../src/card-controller/fullscreen/types';
import { createCardAPI } from '../../test-utils';
vi.mock('../../../src/card-controller/fullscreen/factory');
describe('FullscreenManager', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should initialize', () => {
const api = createCardAPI();
const manager = new FullscreenManager(api, mock<FullscreenProvider>());
manager.initialize();
expect(api.getConditionsManager().setState).toBeCalledWith({ fullscreen: false });
});
it('should correctly determine whether in fullscreen', () => {
const provider = mock<FullscreenProvider>();
const manager = new FullscreenManager(createCardAPI(), provider);
provider.isInFullscreen.mockReturnValue(true);
expect(manager.isInFullscreen()).toBeTruthy();
provider.isInFullscreen.mockReturnValue(false);
expect(manager.isInFullscreen()).toBeFalsy();
});
describe('describe toggle fullscreen', () => {
it.each([
[true, false],
[false, true],
])('%s -> %s', async (state: boolean, expected: boolean) => {
const provider = mock<FullscreenProvider>();
provider.isInFullscreen.mockReturnValue(state);
const manager = new FullscreenManager(createCardAPI(), provider);
manager.toggleFullscreen();
expect(provider.setFullscreen).toBeCalledWith(expected);
});
});
describe('describe set fullscreen', () => {
it.each([[true], [false]])('%s', async (fullscreen: boolean) => {
const provider = mock<FullscreenProvider>();
const manager = new FullscreenManager(createCardAPI(), provider);
manager.setFullscreen(fullscreen);
expect(provider.setFullscreen).toBeCalledWith(fullscreen);
});
});
it('should connect', () => {
const provider = mock<FullscreenProvider>();
const manager = new FullscreenManager(createCardAPI(), provider);
manager.connect();
expect(provider.connect).toBeCalled();
});
it('should disconnect', () => {
const provider = mock<FullscreenProvider>();
const manager = new FullscreenManager(createCardAPI(), provider);
manager.disconnect();
expect(provider.disconnect).toBeCalled();
});
describe('should confirm whether fullscreen is supported', () => {
beforeEach(() => {
vi.stubGlobal('navigator', { userAgent: 'foo' });
});
afterEach(() => {
vi.restoreAllMocks();
});
it('when being casted', () => {
vi.stubGlobal('navigator', { userAgent: 'CrKey/' });
const api = createCardAPI();
const provider = mock<FullscreenProvider>();
provider.isSupported.mockReturnValue(true);
const manager = new FullscreenManager(api, provider);
expect(manager.isSupported()).toBeFalsy();
});
describe('should return provider support', () => {
it.each([[true], [false]])('%s', async (support: boolean) => {
const api = createCardAPI();
const provider = mock<FullscreenProvider>();
provider.isSupported.mockReturnValue(support);
const manager = new FullscreenManager(api, provider);
expect(manager.isSupported()).toBe(support);
});
});
it('without a provider', () => {
vi.mocked(FullscreenProviderFactory).create.mockReturnValue(null);
const manager = new FullscreenManager(createCardAPI());
expect(manager.isSupported()).toBeFalsy();
});
});
describe('should handle state change', () => {
it.each([[true], [false]])('%s', async (fullscreen: boolean) => {
const api = createCardAPI();
const provider = mock<FullscreenProvider>();
vi.mocked(FullscreenProviderFactory.create).mockReturnValue(provider);
new FullscreenManager(api);
const handler = vi.mocked(FullscreenProviderFactory.create).mock.calls[0][1];
provider.isInFullscreen.mockReturnValue(fullscreen);
handler();
expect(api.getConditionsManager().setState).toBeCalledWith({
fullscreen: fullscreen,
});
});
});
it('should create provider from factory', () => {
const api = createCardAPI();
new FullscreenManager(api);
expect(FullscreenProviderFactory.create).toBeCalledWith(api, expect.anything());
});
});
@@ -0,0 +1,162 @@
import screenfull from 'screenfull';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ScreenfullFullScreenProvider } from '../../../../src/card-controller/fullscreen/screenfull';
import { createCardAPI, setScreenfulEnabled } from '../../../test-utils';
vi.mock('screenfull', () => ({
default: {
exit: vi.fn(),
request: vi.fn(),
off: vi.fn(),
on: vi.fn(),
},
}));
const setScreenfulFullscreen = (fullscreen: boolean): void => {
Object.defineProperty(screenfull, 'isFullscreen', {
value: fullscreen,
writable: true,
});
};
// @vitest-environment jsdom
describe('ScreenfullFullScreenProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('should connect', () => {
it('should connect if enabled', () => {
const provider = new ScreenfullFullScreenProvider(createCardAPI(), vi.fn());
const on = vi.mocked(screenfull.on);
setScreenfulEnabled(true);
provider.connect();
expect(on).toBeCalledWith('change', expect.anything());
});
it('should not connect if not enabled', () => {
const provider = new ScreenfullFullScreenProvider(createCardAPI(), vi.fn());
const on = vi.mocked(screenfull.on);
setScreenfulEnabled(false);
provider.connect();
expect(on).not.toBeCalled();
});
});
describe('should disconnect', () => {
it('should disconnect if enabled', () => {
const provider = new ScreenfullFullScreenProvider(createCardAPI(), vi.fn());
const off = vi.mocked(screenfull.off);
setScreenfulEnabled(true);
provider.disconnect();
expect(off).toBeCalledWith('change', expect.anything());
});
it('should not disconnect if not enabled', () => {
const provider = new ScreenfullFullScreenProvider(createCardAPI(), vi.fn());
const off = vi.mocked(screenfull.off);
setScreenfulEnabled(false);
provider.disconnect();
expect(off).not.toBeCalled();
});
});
describe('should return if in fullscreen', () => {
it('should return true if in fullscreen', () => {
const provider = new ScreenfullFullScreenProvider(createCardAPI(), vi.fn());
setScreenfulEnabled(true);
setScreenfulFullscreen(true);
expect(provider.isInFullscreen()).toBeTruthy();
});
it('should return false if not in fullscreen', () => {
const provider = new ScreenfullFullScreenProvider(createCardAPI(), vi.fn());
setScreenfulEnabled(true);
setScreenfulFullscreen(false);
expect(provider.isInFullscreen()).toBeFalsy();
});
it('should return false if not supported', () => {
const provider = new ScreenfullFullScreenProvider(createCardAPI(), vi.fn());
setScreenfulEnabled(false);
setScreenfulFullscreen(true);
expect(provider.isInFullscreen()).toBeFalsy();
});
});
describe('should return if supported', () => {
it('should return true if supported', () => {
const provider = new ScreenfullFullScreenProvider(createCardAPI(), vi.fn());
setScreenfulEnabled(true);
expect(provider.isSupported()).toBeTruthy();
});
it('should return false if not supported', () => {
const provider = new ScreenfullFullScreenProvider(createCardAPI(), vi.fn());
setScreenfulEnabled(false);
expect(provider.isSupported()).toBeFalsy();
});
});
describe('should set fullscreen', () => {
it('should request fullscreen if fullscreen is true', () => {
setScreenfulEnabled(true);
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
const provider = new ScreenfullFullScreenProvider(api, vi.fn());
provider.setFullscreen(true);
expect(screenfull.request).toBeCalledWith(element);
});
it('should exit fullscreen if fullscreen is false', () => {
setScreenfulEnabled(true);
const provider = new ScreenfullFullScreenProvider(createCardAPI(), vi.fn());
provider.setFullscreen(false);
expect(screenfull.exit).toBeCalled();
});
it('should take no action if not supported', () => {
setScreenfulEnabled(false);
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
const provider = new ScreenfullFullScreenProvider(api, vi.fn());
provider.setFullscreen(true);
provider.setFullscreen(false);
expect(screenfull.request).not.toBeCalled();
expect(screenfull.exit).not.toBeCalled();
});
});
});
@@ -0,0 +1,236 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CardController } from '../../../../src/card-controller/controller';
import { WebkitFullScreenProvider } from '../../../../src/card-controller/fullscreen/webkit';
import { FrigateCardMediaPlayer, WebkitHTMLVideoElement } from '../../../../src/types';
import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils';
const createWebkitVideoElement = (): HTMLVideoElement &
Partial<WebkitHTMLVideoElement> => {
return document.createElement('video');
};
const setElement = (api: CardController, element: HTMLElement): void => {
const player = mock<FrigateCardMediaPlayer>();
player.getFullscreenElement.mockReturnValue(element);
vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue(
createMediaLoadedInfo({
player: player,
}),
);
};
// @vitest-environment jsdom
describe('WebkitFullScreenProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('should connect', () => {
const api = createCardAPI();
const provider = new WebkitFullScreenProvider(api, vi.fn());
provider.connect();
expect(api.getConditionsManager().addListener).toBeCalledWith(expect.anything());
});
it('should disconnect', () => {
const api = createCardAPI();
const provider = new WebkitFullScreenProvider(api, vi.fn());
provider.disconnect();
expect(api.getConditionsManager().removeListener).toBeCalledWith(expect.anything());
});
describe('should return if in fullscreen', () => {
it.each([[true], [false]])('%s', async (fullscreen: boolean) => {
const api = createCardAPI();
const provider = new WebkitFullScreenProvider(api, vi.fn());
const element = createWebkitVideoElement();
element.webkitDisplayingFullscreen = fullscreen;
setElement(api, element);
expect(provider.isInFullscreen()).toBe(fullscreen);
});
});
describe('should return if supported', () => {
it.each([[true], [false]])('%s', async (supported: boolean) => {
const api = createCardAPI();
const provider = new WebkitFullScreenProvider(api, vi.fn());
const element = createWebkitVideoElement();
element.webkitSupportsFullscreen = supported;
setElement(api, element);
expect(provider.isSupported()).toBe(supported);
});
});
describe('should set fullscreen', () => {
it('should request fullscreen if fullscreen is true', () => {
const api = createCardAPI();
const provider = new WebkitFullScreenProvider(api, vi.fn());
const element = createWebkitVideoElement();
element.webkitEnterFullscreen = vi.fn();
element.webkitSupportsFullscreen = true;
setElement(api, element);
provider.setFullscreen(true);
expect(element.webkitEnterFullscreen).toBeCalled();
});
it('should exit fullscreen if fullscreen is true', () => {
const api = createCardAPI();
const provider = new WebkitFullScreenProvider(api, vi.fn());
const element = createWebkitVideoElement();
element.webkitExitFullscreen = vi.fn();
element.webkitSupportsFullscreen = true;
setElement(api, element);
provider.setFullscreen(false);
expect(element.webkitExitFullscreen).toBeCalled();
});
it('should take no action if not supported', () => {
const api = createCardAPI();
const provider = new WebkitFullScreenProvider(api, vi.fn());
const element = createWebkitVideoElement();
element.webkitEnterFullscreen = vi.fn();
element.webkitExitFullscreen = vi.fn();
element.webkitSupportsFullscreen = false;
setElement(api, element);
provider.setFullscreen(true);
provider.setFullscreen(false);
expect(element.webkitEnterFullscreen).not.toBeCalled();
expect(element.webkitExitFullscreen).not.toBeCalled();
});
it('should take no action if element is not a video', () => {
const api = createCardAPI();
const provider = new WebkitFullScreenProvider(api, vi.fn());
const element = document.createElement('img') as HTMLImageElement &
Partial<WebkitHTMLVideoElement>;
element.webkitEnterFullscreen = vi.fn();
element.webkitExitFullscreen = vi.fn();
setElement(api, element);
provider.setFullscreen(true);
provider.setFullscreen(false);
expect(element.webkitEnterFullscreen).not.toBeCalled();
expect(element.webkitExitFullscreen).not.toBeCalled();
});
});
describe('should handle state changes', () => {
describe('should call handler when fullscreen state changes', () => {
it.each([['webkitbeginfullscreen'], ['webkitendfullscreen']])(
'%s',
(event: string) => {
const handler = vi.fn();
const api = createCardAPI();
const provider = new WebkitFullScreenProvider(api, handler);
provider.connect();
const conditionChangeHandler = vi.mocked(
api.getConditionsManager().addListener,
).mock.calls[0][0];
const element_1 = createWebkitVideoElement();
const player_1 = mock<FrigateCardMediaPlayer>();
player_1.getFullscreenElement.mockReturnValue(element_1);
const mediaLoadedInfo_1 = createMediaLoadedInfo({ player: player_1 });
conditionChangeHandler({ mediaLoadedInfo: mediaLoadedInfo_1 });
element_1.dispatchEvent(new Event(event));
expect(handler).toBeCalledTimes(1);
const element_2 = createWebkitVideoElement();
const player_2 = mock<FrigateCardMediaPlayer>();
player_2.getFullscreenElement.mockReturnValue(element_2);
const mediaLoadedInfo_2 = createMediaLoadedInfo({ player: player_2 });
conditionChangeHandler(
{ mediaLoadedInfo: mediaLoadedInfo_2 },
{ mediaLoadedInfo: mediaLoadedInfo_1 },
);
element_2.dispatchEvent(new Event(event));
expect(handler).toBeCalledTimes(2);
// Events on the old element should be ignored.
element_1.dispatchEvent(new Event(event));
expect(handler).toBeCalledTimes(2);
// Test the media loaded info not changing.
conditionChangeHandler(
{ mediaLoadedInfo: mediaLoadedInfo_2 },
{ mediaLoadedInfo: mediaLoadedInfo_2 },
);
// Events on the new element should still be handled.
element_2.dispatchEvent(new Event(event));
expect(handler).toBeCalledTimes(3);
},
);
});
});
it('should play the video after fullscreen ends', () => {
vi.useFakeTimers();
const handler = vi.fn();
const api = createCardAPI();
const provider = new WebkitFullScreenProvider(api, handler);
provider.connect();
const conditionChangeHandler = vi.mocked(api.getConditionsManager().addListener).mock
.calls[0][0];
const element = createWebkitVideoElement();
element.play = vi.fn();
setElement(api, element);
const player = mock<FrigateCardMediaPlayer>();
player.getFullscreenElement.mockReturnValue(element);
const mediaLoadedInfo = createMediaLoadedInfo({ player });
conditionChangeHandler({ mediaLoadedInfo });
element.dispatchEvent(new Event('webkitendfullscreen'));
expect(element.play).not.toBeCalled();
vi.runOnlyPendingTimers();
expect(element.play).toBeCalled();
});
});
@@ -8,7 +8,9 @@ describe('MediaLoadedInfoManager', () => {
const manager = new MediaLoadedInfoManager(api);
manager.initialize();
expect(api.getConditionsManager().setState).toBeCalledWith({ media_loaded: false });
expect(api.getConditionsManager().setState).toBeCalledWith({
mediaLoadedInfo: null,
});
});
it('should set', () => {
@@ -21,7 +23,7 @@ describe('MediaLoadedInfoManager', () => {
expect(manager.has()).toBeTruthy();
expect(manager.get()).toBe(mediaInfo);
expect(api.getConditionsManager().setState).toBeCalledWith(
expect.objectContaining({ media_loaded: true }),
expect.objectContaining({ mediaLoadedInfo: mediaInfo }),
);
expect(api.getStyleManager().setExpandedMode).toBeCalled();
expect(api.getCardElementManager().update).toBeCalled();
@@ -30,9 +32,9 @@ describe('MediaLoadedInfoManager', () => {
it('should not set invalid media info', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const mediaInfo = createMediaLoadedInfo({ width: 0, height: 0 });
const mediaLoadedInfo = createMediaLoadedInfo({ width: 0, height: 0 });
manager.set(mediaInfo);
manager.set(mediaLoadedInfo);
expect(manager.has()).toBeFalsy();
expect(manager.get()).toBeNull();
@@ -42,18 +44,18 @@ describe('MediaLoadedInfoManager', () => {
it('should get last known', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const mediaInfo = createMediaLoadedInfo();
const mediaLoadedInfo = createMediaLoadedInfo();
manager.set(mediaInfo);
manager.set(mediaLoadedInfo);
expect(manager.has()).toBeTruthy();
manager.clear();
expect(manager.has()).toBeFalsy();
expect(manager.getLastKnown()).toBe(mediaInfo);
expect(manager.getLastKnown()).toBe(mediaLoadedInfo);
expect(api.getConditionsManager().setState).toBeCalledWith(
expect.objectContaining({ media_loaded: false }),
expect.objectContaining({ mediaLoadedInfo }),
);
});
});
@@ -1,10 +1,11 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import isEqual from 'lodash-es/isEqual';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { Capabilities } from '../../src/camera-manager/capabilities';
import { CameraManager } from '../../src/camera-manager/manager';
import { CameraManagerCameraMetadata } from '../../src/camera-manager/types';
import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager';
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager';
import { MicrophoneManager } from '../../src/card-controller/microphone-manager';
import { ViewManager } from '../../src/card-controller/view/view-manager';
@@ -1121,10 +1122,19 @@ describe('MenuButtonController', () => {
});
describe('should have fullscreen button', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('when not in fullscreen mode', () => {
// Need to write a readonly property.
vi.stubGlobal('navigator', { userAgent: 'foo' });
const buttons = calculateButtons(controller, { inFullscreenMode: false });
const fullscreenManager = mock<FullscreenManager>();
vi.mocked(fullscreenManager.isInFullscreen).mockReturnValue(false);
vi.mocked(fullscreenManager.isSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { fullscreenManager });
expect(buttons).toContainEqual({
icon: 'mdi:fullscreen',
@@ -1139,7 +1149,11 @@ describe('MenuButtonController', () => {
it('when in fullscreen mode', () => {
vi.stubGlobal('navigator', { userAgent: 'foo' });
const buttons = calculateButtons(controller, { inFullscreenMode: true });
const fullscreenManager = mock<FullscreenManager>();
vi.mocked(fullscreenManager.isInFullscreen).mockReturnValue(true);
vi.mocked(fullscreenManager.isSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { fullscreenManager });
expect(buttons).toContainEqual({
icon: 'mdi:fullscreen-exit',
@@ -1151,6 +1165,19 @@ describe('MenuButtonController', () => {
style: { color: 'var(--frigate-card-menu-button-active-color)' },
});
});
it('when not supported', () => {
// Need to write a readonly property.
vi.stubGlobal('navigator', { userAgent: 'foo' });
const fullscreenManager = mock<FullscreenManager>();
vi.mocked(fullscreenManager.isSupported).mockReturnValue(false);
const buttons = calculateButtons(controller, { fullscreenManager });
expect(buttons).not.toContainEqual(
expect.objectContaining({ title: 'Fullscreen' }),
);
});
});
describe('should have expand button', () => {
@@ -1767,8 +1794,11 @@ describe('MenuButtonController', () => {
tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' },
};
controller.addDynamicMenuButton(button);
const fullscreenManager = mock<FullscreenManager>();
vi.mocked(fullscreenManager.isInFullscreen).mockReturnValue(true);
vi.mocked(fullscreenManager.isSupported).mockReturnValue(true);
expect(calculateButtons(controller, { inFullscreenMode: true })).toContainEqual({
expect(calculateButtons(controller, { fullscreenManager })).toContainEqual({
...button,
style: { color: 'var(--frigate-card-menu-button-active-color)' },
});
+6 -1
View File
@@ -1,6 +1,7 @@
import { CurrentUser, HASSDomEvent } from '@dermotduffy/custom-card-helpers';
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
import { LitElement } from 'lit';
import screenfull from 'screenfull';
import { expect, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { Camera } from '../src/camera-manager/camera';
@@ -24,7 +25,7 @@ import { CardController } from '../src/card-controller/controller';
import { DefaultManager } from '../src/card-controller/default-manager';
import { DownloadManager } from '../src/card-controller/download-manager';
import { ExpandManager } from '../src/card-controller/expand-manager';
import { FullscreenManager } from '../src/card-controller/fullscreen-manager';
import { FullscreenManager } from '../src/card-controller/fullscreen/fullscreen-manager';
import { HASSManager } from '../src/card-controller/hass/hass-manager';
import { StateWatcherSubscriptionInterface } from '../src/card-controller/hass/state-watcher';
import { InitializationManager } from '../src/card-controller/initialization-manager';
@@ -535,3 +536,7 @@ export const createInteractionEvent = (
},
});
};
export const setScreenfulEnabled = (enabled: boolean): void => {
Object.defineProperty(screenfull, 'isEnabled', { value: enabled, writable: true });
};