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
@@ -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;