@@ -0,0 +1,10 @@
|
||||
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class PIPAction extends AdvancedCameraCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
await api.getPIPManager().togglePIP();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { NavigateAction } from './actions/navigate';
|
||||
import { NoneAction } from './actions/none';
|
||||
import { PauseAction } from './actions/pause';
|
||||
import { PerformActionAction } from './actions/perform-action';
|
||||
import { PIPAction } from './actions/pip';
|
||||
import { PlayAction } from './actions/play';
|
||||
import { PTZAction } from './actions/ptz';
|
||||
import { PTZControlsAction } from './actions/ptz-controls';
|
||||
@@ -146,6 +147,8 @@ export class ActionFactory {
|
||||
return new PlayAction(context, action, options?.config);
|
||||
case 'pause':
|
||||
return new PauseAction(context, action, options?.config);
|
||||
case 'pip':
|
||||
return new PIPAction(context, action, options?.config);
|
||||
case 'screenshot':
|
||||
return new ScreenshotAction(context, action, options?.config);
|
||||
case 'display_mode_select':
|
||||
|
||||
@@ -69,6 +69,7 @@ export class CardElementManager {
|
||||
this._api.getExpandManager().initialize();
|
||||
this._api.getMediaLoadedInfoManager().initialize();
|
||||
this._api.getMicrophoneManager().initialize();
|
||||
this._api.getPIPManager().initialize();
|
||||
this._api.getKeyboardStateManager().initialize();
|
||||
|
||||
// These initializers are called when the config is updated, but on initial
|
||||
@@ -169,6 +170,7 @@ export class CardElementManager {
|
||||
// When the dashboard 'tab' is changed, the media is effectively unloaded.
|
||||
this._api.getMediaLoadedInfoManager().clear();
|
||||
this._api.getFullscreenManager().disconnect();
|
||||
this._api.getPIPManager().uninitialize();
|
||||
this._api.getKeyboardStateManager().uninitialize();
|
||||
this._api.getActionsManager().uninitialize();
|
||||
this._api.getDefaultManager().uninitialize();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ReactiveController } from 'lit';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { EffectsManager } from './effects/effects-manager';
|
||||
import { ConditionStateManager } from '../conditions/state-manager';
|
||||
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
||||
import { DeviceRegistryManager } from '../ha/registry/device';
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
} from './card-element-manager';
|
||||
import { ConfigManager } from './config/config-manager';
|
||||
import { DefaultManager } from './default-manager';
|
||||
import { EffectsManager } from './effects/effects-manager';
|
||||
import { ExpandManager } from './expand-manager';
|
||||
import { FoldersManager } from './folders/manager';
|
||||
import { FullscreenManager } from './fullscreen/fullscreen-manager';
|
||||
@@ -32,6 +32,7 @@ import { MediaPlayerManager } from './media-player-manager';
|
||||
import { MessageManager } from './message-manager';
|
||||
import { MicrophoneManager } from './microphone-manager';
|
||||
import { OverlayMessageManager } from './overlay-message-manager';
|
||||
import { PIPManager } from './pip-manager';
|
||||
import { QueryStringManager } from './query-string-manager';
|
||||
import { StatusBarItemManager } from './status-bar-item-manager';
|
||||
import { StyleManager } from './style-manager';
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
CardMessageAPI,
|
||||
CardMicrophoneAPI,
|
||||
CardOverlayMessageAPI,
|
||||
CardPIPAPI,
|
||||
CardQueryStringAPI,
|
||||
CardStyleAPI,
|
||||
CardTriggersAPI,
|
||||
@@ -80,6 +82,7 @@ export class CardController
|
||||
CardExpandAPI,
|
||||
CardFullscreenAPI,
|
||||
CardHASSAPI,
|
||||
CardPIPAPI,
|
||||
CardInitializerAPI,
|
||||
CardInteractionAPI,
|
||||
CardKeyboardStateAPI,
|
||||
@@ -123,6 +126,7 @@ export class CardController
|
||||
private _messageManager = new MessageManager(this);
|
||||
private _microphoneManager = new MicrophoneManager(this);
|
||||
private _overlayMessageManager = new OverlayMessageManager(this);
|
||||
private _pipManager = new PIPManager(this);
|
||||
private _queryStringManager = new QueryStringManager(this);
|
||||
private _statusBarItemManager = new StatusBarItemManager(this);
|
||||
private _styleManager = new StyleManager(this);
|
||||
@@ -252,6 +256,10 @@ export class CardController
|
||||
return this._overlayMessageManager;
|
||||
}
|
||||
|
||||
public getPIPManager(): PIPManager {
|
||||
return this._pipManager;
|
||||
}
|
||||
|
||||
public getQueryStringManager(): QueryStringManager {
|
||||
return this._queryStringManager;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ConditionStateChange } from '../conditions/types';
|
||||
import { PIPElement } from '../types';
|
||||
import { CardPIPAPI } from './types';
|
||||
|
||||
export class PIPManager {
|
||||
private _api: CardPIPAPI;
|
||||
private _videoElement: PIPElement | null = null;
|
||||
private _disconnect: (() => void) | null = null;
|
||||
|
||||
constructor(api: CardPIPAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public static isSupported(): boolean {
|
||||
return !!document.pictureInPictureEnabled;
|
||||
}
|
||||
|
||||
public initialize(): void {
|
||||
this._api.getConditionStateManager().addListener(this._stateChangeHandler);
|
||||
}
|
||||
|
||||
public uninitialize(): void {
|
||||
this._trackElement(null);
|
||||
this._api.getConditionStateManager().removeListener(this._stateChangeHandler);
|
||||
}
|
||||
|
||||
public isInPIP(): boolean {
|
||||
return !!document.pictureInPictureElement;
|
||||
}
|
||||
|
||||
public isAvailable(): boolean {
|
||||
return PIPManager.isSupported() && !!this._videoElement;
|
||||
}
|
||||
|
||||
public async togglePIP(): Promise<void> {
|
||||
if (document.pictureInPictureElement) {
|
||||
await document.exitPictureInPicture();
|
||||
} else if (this._videoElement && PIPManager.isSupported()) {
|
||||
await this._videoElement.requestPictureInPicture();
|
||||
}
|
||||
}
|
||||
|
||||
private _stateChangeHandler = (change: ConditionStateChange): void => {
|
||||
const oldElement =
|
||||
change.old.mediaLoadedInfo?.mediaPlayerController?.getPIPElement() ?? null;
|
||||
const newElement =
|
||||
change.new.mediaLoadedInfo?.mediaPlayerController?.getPIPElement() ?? null;
|
||||
|
||||
if (oldElement !== newElement) {
|
||||
// Exit PIP when the video element is destroyed (e.g. view change) to
|
||||
// avoid a black/empty PIP window.
|
||||
if (!newElement && this.isInPIP()) {
|
||||
// Ignore errors in existing PIP.
|
||||
document.exitPictureInPicture().catch(() => {});
|
||||
}
|
||||
this._trackElement(newElement);
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
};
|
||||
|
||||
private _trackElement(element: PIPElement | null): void {
|
||||
this._disconnect?.();
|
||||
this._disconnect = null;
|
||||
this._videoElement = element;
|
||||
|
||||
if (element) {
|
||||
const handler = (): void => this._api.getCardElementManager().update();
|
||||
element.addEventListener('enterpictureinpicture', handler);
|
||||
element.addEventListener('leavepictureinpicture', handler);
|
||||
this._disconnect = () => {
|
||||
element.removeEventListener('enterpictureinpicture', handler);
|
||||
element.removeEventListener('leavepictureinpicture', handler);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type { MediaPlayerManager } from './media-player-manager';
|
||||
import type { MessageManager } from './message-manager';
|
||||
import type { MicrophoneManager } from './microphone-manager';
|
||||
import type { OverlayMessageManager } from './overlay-message-manager';
|
||||
import type { PIPManager } from './pip-manager';
|
||||
import type { QueryStringManager } from './query-string-manager';
|
||||
import type { StatusBarItemManager } from './status-bar-item-manager';
|
||||
import type { StyleManager } from './style-manager';
|
||||
@@ -54,6 +55,7 @@ export interface CardActionsAPI {
|
||||
getMessageManager(): MessageManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getOverlayMessageManager(): OverlayMessageManager;
|
||||
getPIPManager(): PIPManager;
|
||||
getStatusBarItemManager(): StatusBarItemManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getViewItemManager(): ViewItemManager;
|
||||
@@ -150,6 +152,7 @@ export interface CardElementAPI {
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
getMediaPlayerManager(): MediaPlayerManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getPIPManager(): PIPManager;
|
||||
getQueryStringManager(): QueryStringManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
@@ -174,6 +177,11 @@ export interface CardFullscreenAPI {
|
||||
getMediaPlayerManager(): MediaPlayerManager;
|
||||
}
|
||||
|
||||
export interface CardPIPAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
}
|
||||
|
||||
export interface CardHASSAPI {
|
||||
getCameraManager(): CameraManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
|
||||
@@ -264,6 +264,7 @@ class AdvancedCameraCard extends LitElement {
|
||||
inExpandedMode: this._controller.getExpandManager().isExpanded(),
|
||||
mediaPlayerController: this._controller.getMediaPlayerManager(),
|
||||
microphoneManager: this._controller.getMicrophoneManager(),
|
||||
pipManager: this._controller.getPIPManager(),
|
||||
showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(),
|
||||
view: view,
|
||||
viewManager: this._controller.getViewManager(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LitElement } from 'lit';
|
||||
import { FullscreenElement, MediaPlayerController } from '../../types';
|
||||
import { FullscreenElement, MediaPlayerController, PIPElement } from '../../types';
|
||||
import { screenshotImage } from '../../utils/screenshot';
|
||||
|
||||
export class ImageMediaPlayerController implements MediaPlayerController {
|
||||
@@ -58,4 +58,8 @@ export class ImageMediaPlayerController implements MediaPlayerController {
|
||||
public getFullscreenElement(): FullscreenElement | null {
|
||||
return this._getImageCallback() ?? null;
|
||||
}
|
||||
|
||||
public getPIPElement(): PIPElement | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||
import { LitElement } from 'lit';
|
||||
import { FullscreenElement, MediaPlayerController } from '../../types';
|
||||
import { FullscreenElement, MediaPlayerController, PIPElement } from '../../types';
|
||||
|
||||
export class JSMPEGMediaPlayerController implements MediaPlayerController {
|
||||
private _host: LitElement;
|
||||
@@ -70,4 +70,9 @@ export class JSMPEGMediaPlayerController implements MediaPlayerController {
|
||||
public getFullscreenElement(): FullscreenElement | null {
|
||||
return this._getCanvasElementCallback() ?? null;
|
||||
}
|
||||
|
||||
public getPIPElement(): PIPElement | null {
|
||||
// JSMpeg renders to a canvas, not a video element.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LitElement } from 'lit';
|
||||
import { FullscreenElement, MediaPlayerController } from '../../types';
|
||||
import { FullscreenElement, MediaPlayerController, PIPElement } from '../../types';
|
||||
import { CachedValueController } from '../cached-value-controller';
|
||||
|
||||
export class UpdatingImageMediaPlayerController implements MediaPlayerController {
|
||||
@@ -61,4 +61,8 @@ export class UpdatingImageMediaPlayerController implements MediaPlayerController
|
||||
public getFullscreenElement(): FullscreenElement | null {
|
||||
return this._getImageCallback() ?? null;
|
||||
}
|
||||
|
||||
public getPIPElement(): PIPElement | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LitElement } from 'lit';
|
||||
import { FullscreenElement, MediaPlayerController } from '../../types';
|
||||
import { FullscreenElement, MediaPlayerController, PIPElement } from '../../types';
|
||||
import { hideMediaControlsTemporarily, setControlsOnVideo } from '../../utils/controls';
|
||||
import { screenshotVideo } from '../../utils/screenshot';
|
||||
|
||||
@@ -107,4 +107,8 @@ export class VideoMediaPlayerController implements MediaPlayerController {
|
||||
public getFullscreenElement(): FullscreenElement | null {
|
||||
return this._getVideoCallback() ?? null;
|
||||
}
|
||||
|
||||
public getPIPElement(): PIPElement | null {
|
||||
return this._getVideoCallback() ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FoldersManager } from '../card-controller/folders/manager';
|
||||
import { FullscreenManager } from '../card-controller/fullscreen/fullscreen-manager';
|
||||
import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
||||
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
||||
import { PIPManager } from '../card-controller/pip-manager';
|
||||
import { ViewManager } from '../card-controller/view/view-manager';
|
||||
import {
|
||||
AdvancedCameraCardView,
|
||||
@@ -45,6 +46,7 @@ export interface MenuButtonControllerOptions {
|
||||
inExpandedMode?: boolean;
|
||||
microphoneManager?: MicrophoneManager | null;
|
||||
mediaPlayerController?: MediaPlayerManager | null;
|
||||
pipManager?: PIPManager | null;
|
||||
viewManager?: ViewManager | null;
|
||||
view?: View | null;
|
||||
}
|
||||
@@ -100,6 +102,7 @@ export class MenuButtonController {
|
||||
),
|
||||
this._getExpandButton(config, options?.inExpandedMode),
|
||||
this._getFullscreenButton(config, options?.fullscreenManager),
|
||||
this._getPIPButton(config, options?.pipManager),
|
||||
this._getCastButton(
|
||||
hass,
|
||||
config,
|
||||
@@ -550,6 +553,25 @@ export class MenuButtonController {
|
||||
: null;
|
||||
}
|
||||
|
||||
private _getPIPButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
pipManager?: PIPManager | null,
|
||||
): MenuItem | null {
|
||||
const inPIP = pipManager?.isInPIP();
|
||||
return pipManager?.isAvailable()
|
||||
? {
|
||||
icon: inPIP
|
||||
? 'mdi:picture-in-picture-bottom-right-outline'
|
||||
: 'mdi:picture-in-picture-bottom-right',
|
||||
...config.menu.buttons.pip,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: localize('config.menu.buttons.pip'),
|
||||
tap_action: createGeneralAction('pip'),
|
||||
style: inPIP ? this._getEmphasizedStyle() : {},
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
private _getCastButton(
|
||||
hass: HomeAssistant,
|
||||
config: AdvancedCameraCardConfig,
|
||||
|
||||
@@ -17,6 +17,7 @@ const GENERAL_ACTIONS = [
|
||||
'microphone_unmute',
|
||||
'mute',
|
||||
'pause',
|
||||
'pip',
|
||||
'play',
|
||||
'reload',
|
||||
'screenshot',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { statusBarItemBaseSchema } from '../common/status-bar';
|
||||
import { severitySchema } from '../common/severity';
|
||||
import { statusBarItemBaseSchema } from '../common/status-bar';
|
||||
import { advancedCameraCardCustomActionsBaseSchema } from './custom/base';
|
||||
import { cameraSelectActionConfigSchema } from './custom/camera-select';
|
||||
import { viewDisplayModeActionConfigSchema } from './custom/display-mode';
|
||||
|
||||
@@ -56,6 +56,7 @@ export const menuConfigDefault = {
|
||||
type: 'momentary' as const,
|
||||
},
|
||||
mute: { ...hiddenButtonDefault },
|
||||
pip: { ...hiddenButtonDefault },
|
||||
play: { ...hiddenButtonDefault },
|
||||
ptz_controls: { ...hiddenButtonDefault },
|
||||
recordings: { ...hiddenButtonDefault },
|
||||
@@ -114,6 +115,7 @@ export const menuConfigSchema = z
|
||||
})
|
||||
.default(menuConfigDefault.buttons.microphone),
|
||||
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
||||
pip: hiddenButtonSchema.default(menuConfigDefault.buttons.pip),
|
||||
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
||||
ptz_controls: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_controls),
|
||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
||||
|
||||
@@ -3067,6 +3067,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
)}`,
|
||||
)}
|
||||
${this._renderMenuButton('mute') /* */}
|
||||
${this._renderMenuButton('pip') /* */}
|
||||
${this._renderMenuButton('play')}
|
||||
${this._renderMenuButton('ptz_controls')}
|
||||
${this._renderMenuButton('ptz_home')}
|
||||
|
||||
@@ -482,6 +482,7 @@
|
||||
"microphone": "Microphone",
|
||||
"mute": "Mute / Unmute",
|
||||
"permanent": "Show when the menu is hidden",
|
||||
"pip": "Picture in Picture",
|
||||
"play": "Play / Pause",
|
||||
"priority": "Priority",
|
||||
"ptz_controls": "Show PTZ controls",
|
||||
|
||||
@@ -90,6 +90,7 @@ export type WebkitHTMLVideoElement = HTMLVideoElement & {
|
||||
};
|
||||
|
||||
export type FullscreenElement = HTMLElement;
|
||||
export type PIPElement = HTMLVideoElement;
|
||||
|
||||
export interface MediaPlayerController {
|
||||
play(): Promise<void>;
|
||||
@@ -103,6 +104,7 @@ export interface MediaPlayerController {
|
||||
setControls(controls?: boolean): Promise<void>;
|
||||
isPaused(): boolean;
|
||||
getFullscreenElement(): FullscreenElement | null;
|
||||
getPIPElement(): PIPElement | null;
|
||||
}
|
||||
|
||||
export interface MediaPlayer {
|
||||
|
||||
Reference in New Issue
Block a user