feat: Add PIP (Picture-in-Picture) support (#2391)

- Closes: #1657
This commit is contained in:
Dermot Duffy
2026-03-05 20:19:20 -08:00
committed by GitHub
parent ecc8ecfd7e
commit c398562d40
32 changed files with 735 additions and 14 deletions
@@ -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();
}
}
+3
View File
@@ -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();
+9 -1
View File
@@ -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;
}
+76
View File
@@ -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);
};
}
}
}
+8
View File
@@ -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;