diff --git a/docs/configuration/menu.md b/docs/configuration/menu.md index f7c320b1..43e615d9 100644 --- a/docs/configuration/menu.md +++ b/docs/configuration/menu.md @@ -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. | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8f3ed8d8..fab54452 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index 499a3d09..c68be81e 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -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 diff --git a/src/card-controller/conditions-manager.ts b/src/card-controller/conditions-manager.ts index 727caf90..6b9fd23f 100644 --- a/src/card-controller/conditions-manager.ts +++ b/src/card-controller/conditions-manager.ts @@ -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; + expand?: boolean; + fullscreen?: boolean; interaction?: boolean; - microphone?: MicrophoneState; - user?: CurrentUser; keys?: KeysState; - user_agent?: string; + mediaLoadedInfo?: MediaLoadedInfo | null; + microphone?: MicrophoneState; + state?: HassEntities; + triggered?: Set; + user?: CurrentUser; + userAgent?: string; + view?: string; } class OverrideConfigurationError extends FrigateCardError {} @@ -152,7 +152,10 @@ export interface ConditionsManagerEpoch { manager: Readonly; } -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)); } } diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index 8158015b..4769c87d 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -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'; diff --git a/src/card-controller/expand-manager.ts b/src/card-controller/expand-manager.ts index 2a112136..70d8f42b 100644 --- a/src/card-controller/expand-manager.ts +++ b/src/card-controller/expand-manager.ts @@ -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; diff --git a/src/card-controller/fullscreen-manager.ts b/src/card-controller/fullscreen-manager.ts deleted file mode 100644 index a60d5dfe..00000000 --- a/src/card-controller/fullscreen-manager.ts +++ /dev/null @@ -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(); - }; -} diff --git a/src/card-controller/fullscreen/factory.ts b/src/card-controller/fullscreen/factory.ts new file mode 100644 index 00000000..4de5878b --- /dev/null +++ b/src/card-controller/fullscreen/factory.ts @@ -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; + if (!!video.webkitEnterFullscreen) { + return new WebkitFullScreenProvider(api, handler); + } + + return null; + } +} diff --git a/src/card-controller/fullscreen/fullscreen-manager.ts b/src/card-controller/fullscreen/fullscreen-manager.ts new file mode 100644 index 00000000..4faafa9a --- /dev/null +++ b/src/card-controller/fullscreen/fullscreen-manager.ts @@ -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(), + }); + } +} diff --git a/src/card-controller/fullscreen/provider.ts b/src/card-controller/fullscreen/provider.ts new file mode 100644 index 00000000..5d508f73 --- /dev/null +++ b/src/card-controller/fullscreen/provider.ts @@ -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; + } +} diff --git a/src/card-controller/fullscreen/screenfull/index.ts b/src/card-controller/fullscreen/screenfull/index.ts new file mode 100644 index 00000000..03a02f16 --- /dev/null +++ b/src/card-controller/fullscreen/screenfull/index.ts @@ -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(); + } + } +} diff --git a/src/card-controller/fullscreen/types.ts b/src/card-controller/fullscreen/types.ts new file mode 100644 index 00000000..f319bc83 --- /dev/null +++ b/src/card-controller/fullscreen/types.ts @@ -0,0 +1,11 @@ +export type FullscreenHandler = () => void; + +export interface FullscreenProvider { + connect(): void; + disconnect(): void; + + isInFullscreen(): boolean; + isSupported(): boolean; + + setFullscreen(fullscreen: boolean): void; +} diff --git a/src/card-controller/fullscreen/webkit/index.ts b/src/card-controller/fullscreen/webkit/index.ts new file mode 100644 index 00000000..6927f3c1 --- /dev/null +++ b/src/card-controller/fullscreen/webkit/index.ts @@ -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) + | 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(); + }); + }; +} diff --git a/src/card-controller/media-info-manager.ts b/src/card-controller/media-info-manager.ts index 78878996..d269e325 100644 --- a/src/card-controller/media-info-manager.ts +++ b/src/card-controller/media-info-manager.ts @@ -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 { diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index f78a20ff..ce7190ce 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -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; } diff --git a/src/card.ts b/src/card.ts index 1faa4146..cb0b739e 100644 --- a/src/card.ts +++ b/src/card.ts @@ -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(), diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 557cc5e2..17131539 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -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) ) { diff --git a/src/components/image-base.ts b/src/components/image-base.ts index b373943c..9e59ca79 100644 --- a/src/components/image-base.ts +++ b/src/components/image-base.ts @@ -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. diff --git a/src/components/image.ts b/src/components/image.ts index 47f57ab0..e340120e 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -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 ( diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 2eeb223c..b9b67e26 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -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'). diff --git a/src/components/live/providers/go2rtc/index.ts b/src/components/live/providers/go2rtc/index.ts index 2f7c57af..045d844b 100644 --- a/src/components/live/providers/go2rtc/index.ts +++ b/src/components/live/providers/go2rtc/index.ts @@ -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 ( diff --git a/src/components/live/providers/go2rtc/video-rtc.d.ts b/src/components/live/providers/go2rtc/video-rtc.d.ts index 3da8d890..3ed4fc9d 100644 --- a/src/components/live/providers/go2rtc/video-rtc.d.ts +++ b/src/components/live/providers/go2rtc/video-rtc.d.ts @@ -29,8 +29,9 @@ export class VideoRTC extends HTMLElement { onmessage: Record void>; // Custom methods/members. - controls: boolean; containingPlayer: FrigateCardMediaPlayer | null; microphoneStream: MediaStream | null; reconnect(); + + setControls(controls: boolean): void; } diff --git a/src/components/live/providers/go2rtc/video-rtc.js b/src/components/live/providers/go2rtc/video-rtc.js index 7ab76783..8a1e6b10 100644 --- a/src/components/live/providers/go2rtc/video-rtc.js +++ b/src/components/live/providers/go2rtc/video-rtc.js @@ -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 diff --git a/src/components/live/providers/ha.ts b/src/components/live/providers/ha.ts index bd31325f..ff4dd4df 100644 --- a/src/components/live/providers/ha.ts +++ b/src/components/live/providers/ha.ts @@ -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; diff --git a/src/components/live/providers/image.ts b/src/components/live/providers/image.ts index 8cdb8518..88c29eda 100644 --- a/src/components/live/providers/image.ts +++ b/src/components/live/providers/image.ts @@ -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; diff --git a/src/components/live/providers/jsmpeg.ts b/src/components/live/providers/jsmpeg.ts index 062425fe..e4063c05 100644 --- a/src/components/live/providers/jsmpeg.ts +++ b/src/components/live/providers/jsmpeg.ts @@ -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)) diff --git a/src/components/live/providers/webrtc-card.ts b/src/components/live/providers/webrtc-card.ts index b1f69a0f..d9b36532 100644 --- a/src/components/live/providers/webrtc-card.ts +++ b/src/components/live/providers/webrtc-card.ts @@ -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 { - return this._getPlayer()?.play(); + return this._getVideo()?.play(); } public async pause(): Promise { - this._getPlayer()?.pause(); + this._getVideo()?.pause(); } public async mute(): Promise { - const player = this._getPlayer(); + const player = this._getVideo(); if (player) { player.muted = true; } } public async unmute(): Promise { - 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 { - const player = this._getPlayer(); + const player = this._getVideo(); if (player) { player.currentTime = seconds; } } public async setControls(controls?: boolean): Promise { - 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 { - 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 = () => { diff --git a/src/components/viewer/provider.ts b/src/components/viewer/provider.ts index b9b7376d..0c10ca8d 100644 --- a/src/components/viewer/provider.ts +++ b/src/components/viewer/provider.ts @@ -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 { const view = this.viewManagerEpoch?.manager.getView(); if ( diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index ad804251..ff2c3fd5 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -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, diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index c9e5e5f2..d7a897dd 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -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 diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index 3fc3361c..839098e6 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -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 diff --git a/src/types.ts b/src/types.ts index 6e74e57a..85471470 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; pause(): Promise; @@ -65,6 +74,7 @@ export interface FrigateCardMediaPlayer { // If no value for controls if specified, the player should use the default. setControls(controls?: boolean): Promise; isPaused(): boolean; + getFullscreenElement(): FullscreenElement | null; } export type LovelaceCardWithEditor = LovelaceCard & { diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts index 5eda9dbd..7c0f073d 100644 --- a/src/utils/diagnostics.ts +++ b/src/utils/diagnostics.ts @@ -36,7 +36,7 @@ export const getReleaseVersion = (): string => { return releaseVersion; }; -export interface Diagnostics { +interface Diagnostics { card_version: string; browser: string; date: Date; diff --git a/tests/card-controller/conditions-manager.test.ts b/tests/card-controller/conditions-manager.test.ts index afbb7cac..5243331d 100644 --- a/tests/card-controller/conditions-manager.test.ts +++ b/tests/card-controller/conditions-manager.test.ts @@ -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 => { + 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(); + }); }); diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index 9e30cb1f..e293ab32 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -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'); diff --git a/tests/card-controller/expand-manager.test.ts b/tests/card-controller/expand-manager.test.ts index 1417c064..76fea291 100644 --- a/tests/card-controller/expand-manager.test.ts +++ b/tests/card-controller/expand-manager.test.ts @@ -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', () => { diff --git a/tests/card-controller/fullscreen-manager.test.ts b/tests/card-controller/fullscreen-manager.test.ts deleted file mode 100644 index 7615811e..00000000 --- a/tests/card-controller/fullscreen-manager.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/tests/card-controller/fullscreen/factory.test.ts b/tests/card-controller/fullscreen/factory.test.ts new file mode 100644 index 00000000..aedc9755 --- /dev/null +++ b/tests/card-controller/fullscreen/factory.test.ts @@ -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; + element['webkitEnterFullscreen'] = vi.fn(); + + const stubDocument = mock(); + 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; + element['webkitEnterFullscreen'] = undefined; + + const stubDocument = mock(); + stubDocument.createElement.mockReturnValue(element); + + vi.stubGlobal('document', stubDocument); + + expect(FullscreenProviderFactory.create(createCardAPI(), vi.fn())).toBeNull(); + }); +}); diff --git a/tests/card-controller/fullscreen/fullscreen-manager.test.ts b/tests/card-controller/fullscreen/fullscreen-manager.test.ts new file mode 100644 index 00000000..23888f93 --- /dev/null +++ b/tests/card-controller/fullscreen/fullscreen-manager.test.ts @@ -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()); + + manager.initialize(); + expect(api.getConditionsManager().setState).toBeCalledWith({ fullscreen: false }); + }); + + it('should correctly determine whether in fullscreen', () => { + const provider = mock(); + 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(); + 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(); + const manager = new FullscreenManager(createCardAPI(), provider); + + manager.setFullscreen(fullscreen); + + expect(provider.setFullscreen).toBeCalledWith(fullscreen); + }); + }); + + it('should connect', () => { + const provider = mock(); + const manager = new FullscreenManager(createCardAPI(), provider); + + manager.connect(); + + expect(provider.connect).toBeCalled(); + }); + + it('should disconnect', () => { + const provider = mock(); + 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(); + 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(); + 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(); + 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()); + }); +}); diff --git a/tests/card-controller/fullscreen/screenfull/index.test.ts b/tests/card-controller/fullscreen/screenfull/index.test.ts new file mode 100644 index 00000000..c461b55d --- /dev/null +++ b/tests/card-controller/fullscreen/screenfull/index.test.ts @@ -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(); + }); + }); +}); diff --git a/tests/card-controller/fullscreen/webkit/index.test.ts b/tests/card-controller/fullscreen/webkit/index.test.ts new file mode 100644 index 00000000..97ecba06 --- /dev/null +++ b/tests/card-controller/fullscreen/webkit/index.test.ts @@ -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 => { + return document.createElement('video'); +}; + +const setElement = (api: CardController, element: HTMLElement): void => { + const player = mock(); + 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; + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/tests/card-controller/media-info-manager.test.ts b/tests/card-controller/media-info-manager.test.ts index 3f2969d3..bf8b1e18 100644 --- a/tests/card-controller/media-info-manager.test.ts +++ b/tests/card-controller/media-info-manager.test.ts @@ -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 }), ); }); }); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 4192f70d..065ed454 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -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(); + 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(); + 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(); + 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(); + 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)' }, }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 7c73bcdd..c93a967a 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -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 }); +};