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,18 @@
import { expect, it } from 'vitest';
import { PIPAction } from '../../../../src/card-controller/actions/actions/pip';
import { createCardAPI } from '../../../test-utils';
it('should toggle PIP', async () => {
const api = createCardAPI();
const action = new PIPAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'pip',
},
);
await action.execute(api);
expect(api.getPIPManager().togglePIP).toBeCalled();
});
@@ -23,6 +23,7 @@ import { MuteAction } from '../../../src/card-controller/actions/actions/mute';
import { NavigateAction } from '../../../src/card-controller/actions/actions/navigate';
import { NoneAction } from '../../../src/card-controller/actions/actions/none';
import { PauseAction } from '../../../src/card-controller/actions/actions/pause';
import { PIPAction } from '../../../src/card-controller/actions/actions/pip';
import { PerformActionAction } from '../../../src/card-controller/actions/actions/perform-action';
import { PlayAction } from '../../../src/card-controller/actions/actions/play';
import { PTZAction } from '../../../src/card-controller/actions/actions/ptz';
@@ -152,6 +153,7 @@ describe('ActionFactory', () => {
],
[{ advanced_camera_card_action: 'mute' as const }, MuteAction],
[{ advanced_camera_card_action: 'pause' as const }, PauseAction],
[{ advanced_camera_card_action: 'pip' as const }, PIPAction],
[{ advanced_camera_card_action: 'play' as const }, PlayAction],
[{ advanced_camera_card_action: 'ptz_digital' as const }, PTZDigitalAction],
[
+8
View File
@@ -22,6 +22,7 @@ import { MediaPlayerManager } from '../../src/card-controller/media-player-manag
import { MessageManager } from '../../src/card-controller/message-manager';
import { MicrophoneManager } from '../../src/card-controller/microphone-manager';
import { OverlayMessageManager } from '../../src/card-controller/overlay-message-manager';
import { PIPManager } from '../../src/card-controller/pip-manager';
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager';
import { StyleManager } from '../../src/card-controller/style-manager';
@@ -54,6 +55,7 @@ vi.mock('../../src/card-controller/media-player-manager');
vi.mock('../../src/card-controller/message-manager');
vi.mock('../../src/card-controller/microphone-manager');
vi.mock('../../src/card-controller/overlay-message-manager');
vi.mock('../../src/card-controller/pip-manager');
vi.mock('../../src/card-controller/query-string-manager');
vi.mock('../../src/card-controller/status-bar-item-manager');
vi.mock('../../src/card-controller/style-manager');
@@ -230,6 +232,12 @@ describe('CardController', () => {
);
});
it('getPIPManager', () => {
expect(createController().getPIPManager()).toBe(
vi.mocked(PIPManager).mock.instances[0],
);
});
it('getMicrophoneManager', () => {
expect(createController().getMicrophoneManager()).toBe(
vi.mocked(MicrophoneManager).mock.instances[0],
+369
View File
@@ -0,0 +1,369 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CardController } from '../../src/card-controller/controller';
import { PIPManager } from '../../src/card-controller/pip-manager';
import { ConditionStateManager } from '../../src/conditions/state-manager';
import { MediaPlayerController } from '../../src/types';
import { createCardAPI, createMediaLoadedInfo, flushPromises } from '../test-utils';
const stubPIPSupported = (enabled = true): void => {
Object.defineProperty(document, 'pictureInPictureEnabled', {
value: enabled,
writable: true,
configurable: true,
});
};
const stubPIPElement = (element: Element | null): void => {
Object.defineProperty(document, 'pictureInPictureElement', {
value: element,
writable: true,
configurable: true,
});
};
const createVideoElement = (): HTMLVideoElement => {
const video = document.createElement('video');
video.requestPictureInPicture = vi.fn().mockResolvedValue({});
return video;
};
const createMediaPlayerControllerWithPIP = (
video: HTMLVideoElement,
): MediaPlayerController => {
const controller = mock<MediaPlayerController>();
controller.getPIPElement.mockReturnValue(video);
return controller;
};
const setupWithVideo = (api: CardController) => {
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new PIPManager(api);
manager.initialize();
const video = createVideoElement();
stateManager.setState({
mediaLoadedInfo: createMediaLoadedInfo({
mediaPlayerController: createMediaPlayerControllerWithPIP(video),
}),
});
return { manager, stateManager, video };
};
// @vitest-environment jsdom
describe('PIPManager', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
// Reset document PIP properties.
Object.defineProperty(document, 'pictureInPictureEnabled', {
value: undefined,
writable: true,
configurable: true,
});
Object.defineProperty(document, 'pictureInPictureElement', {
value: undefined,
writable: true,
configurable: true,
});
});
describe('isSupported', () => {
it('returns true when pictureInPictureEnabled is true', () => {
stubPIPSupported(true);
expect(PIPManager.isSupported()).toBe(true);
});
it('returns false when pictureInPictureEnabled is falsy', () => {
expect(PIPManager.isSupported()).toBeFalsy();
});
});
describe('initialize', () => {
it('registers a listener with the condition state manager', () => {
const api = createCardAPI();
const manager = new PIPManager(api);
manager.initialize();
expect(api.getConditionStateManager().addListener).toBeCalledWith(
expect.anything(),
);
});
});
describe('uninitialize', () => {
it('removes the listener from the condition state manager', () => {
const api = createCardAPI();
const manager = new PIPManager(api);
manager.uninitialize();
expect(api.getConditionStateManager().removeListener).toBeCalledWith(
expect.anything(),
);
});
it('clears video element reference', () => {
stubPIPSupported();
const api = createCardAPI();
const { manager } = setupWithVideo(api);
expect(manager.isAvailable()).toBe(true);
manager.uninitialize();
expect(manager.isAvailable()).toBe(false);
});
it('removes event listeners from tracked video', () => {
stubPIPSupported();
const api = createCardAPI();
const { manager, video } = setupWithVideo(api);
const removeSpy = vi.spyOn(video, 'removeEventListener');
manager.uninitialize();
expect(removeSpy).toBeCalledWith('enterpictureinpicture', expect.any(Function));
expect(removeSpy).toBeCalledWith('leavepictureinpicture', expect.any(Function));
});
});
describe('isInPIP', () => {
it('returns false when no video element is tracked', () => {
const api = createCardAPI();
const manager = new PIPManager(api);
expect(manager.isInPIP()).toBe(false);
});
it('returns true when any element is in PIP', () => {
const api = createCardAPI();
const manager = new PIPManager(api);
stubPIPElement(document.createElement('video'));
expect(manager.isInPIP()).toBe(true);
});
});
describe('isAvailable', () => {
it('returns true when PIP is supported and a video is loaded', () => {
stubPIPSupported();
const api = createCardAPI();
const { manager } = setupWithVideo(api);
expect(manager.isAvailable()).toBe(true);
});
it('returns false when PIP is not supported', () => {
const api = createCardAPI();
const manager = new PIPManager(api);
manager.initialize();
expect(manager.isAvailable()).toBe(false);
});
it('returns false when no video is loaded', () => {
stubPIPSupported();
const api = createCardAPI();
const manager = new PIPManager(api);
manager.initialize();
expect(manager.isAvailable()).toBe(false);
});
});
describe('media element tracking', () => {
it('tracks the video element from mediaLoadedInfo', () => {
stubPIPSupported();
const api = createCardAPI();
const { manager } = setupWithVideo(api);
expect(manager.isAvailable()).toBe(true);
expect(api.getCardElementManager().update).toBeCalled();
});
it('removes listeners when the media element changes', () => {
stubPIPSupported();
const api = createCardAPI();
const { stateManager, video } = setupWithVideo(api);
const removeSpy = vi.spyOn(video, 'removeEventListener');
const video2 = createVideoElement();
stateManager.setState({
mediaLoadedInfo: createMediaLoadedInfo({
mediaPlayerController: createMediaPlayerControllerWithPIP(video2),
}),
});
expect(removeSpy).toBeCalledWith('enterpictureinpicture', expect.any(Function));
expect(removeSpy).toBeCalledWith('leavepictureinpicture', expect.any(Function));
});
it('does not re-track when element is unchanged', () => {
stubPIPSupported();
const api = createCardAPI();
const { stateManager, video } = setupWithVideo(api);
const addSpy = vi.spyOn(video, 'addEventListener');
addSpy.mockClear();
stateManager.setState({ interaction: true });
expect(addSpy).not.toBeCalledWith('enterpictureinpicture', expect.any(Function));
});
it('clears video element when media is unloaded', () => {
stubPIPSupported();
const api = createCardAPI();
const { manager, stateManager } = setupWithVideo(api);
expect(manager.isAvailable()).toBe(true);
stateManager.setState({
mediaLoadedInfo: createMediaLoadedInfo(),
});
expect(manager.isAvailable()).toBe(false);
});
it('exits PIP when the video element is destroyed', () => {
stubPIPSupported();
const api = createCardAPI();
const { stateManager, video } = setupWithVideo(api);
stubPIPElement(video);
const exitPIP = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(document, 'exitPictureInPicture', {
value: exitPIP,
writable: true,
configurable: true,
});
stateManager.setState({
mediaLoadedInfo: createMediaLoadedInfo(),
});
expect(exitPIP).toBeCalled();
});
it('handles exitPictureInPicture rejection gracefully', async () => {
stubPIPSupported();
const api = createCardAPI();
const { stateManager, video } = setupWithVideo(api);
stubPIPElement(video);
const exitPIP = vi.fn().mockRejectedValue(new Error('fail'));
Object.defineProperty(document, 'exitPictureInPicture', {
value: exitPIP,
writable: true,
configurable: true,
});
stateManager.setState({
mediaLoadedInfo: createMediaLoadedInfo(),
});
expect(exitPIP).toBeCalled();
// Ensure the rejection is caught and does not throw.
await flushPromises();
});
it('does not exit PIP when video element changes to a new one', () => {
stubPIPSupported();
const api = createCardAPI();
const { stateManager, video } = setupWithVideo(api);
stubPIPElement(video);
const exitPIP = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(document, 'exitPictureInPicture', {
value: exitPIP,
writable: true,
configurable: true,
});
const video2 = createVideoElement();
stateManager.setState({
mediaLoadedInfo: createMediaLoadedInfo({
mediaPlayerController: createMediaPlayerControllerWithPIP(video2),
}),
});
expect(exitPIP).not.toBeCalled();
});
});
describe('native PIP detection', () => {
it('updates card when native PIP is entered', () => {
stubPIPSupported();
const api = createCardAPI();
const { manager, video } = setupWithVideo(api);
stubPIPElement(video);
video.dispatchEvent(new Event('enterpictureinpicture'));
expect(manager.isInPIP()).toBe(true);
expect(api.getCardElementManager().update).toBeCalled();
});
it('updates card when PIP is exited via leavepictureinpicture', () => {
stubPIPSupported();
const api = createCardAPI();
const { manager, video } = setupWithVideo(api);
stubPIPElement(video);
video.dispatchEvent(new Event('enterpictureinpicture'));
expect(manager.isInPIP()).toBe(true);
stubPIPElement(null);
video.dispatchEvent(new Event('leavepictureinpicture'));
expect(manager.isInPIP()).toBe(false);
});
});
describe('togglePIP', () => {
it('enters PIP when a video is available', async () => {
stubPIPSupported();
const api = createCardAPI();
const { manager, video } = setupWithVideo(api);
await manager.togglePIP();
expect(video.requestPictureInPicture).toBeCalled();
});
it('exits PIP when currently in PIP', async () => {
stubPIPSupported();
const api = createCardAPI();
const { manager, video } = setupWithVideo(api);
stubPIPElement(video);
Object.defineProperty(document, 'exitPictureInPicture', {
value: vi.fn().mockResolvedValue(undefined),
writable: true,
configurable: true,
});
await manager.togglePIP();
expect(document.exitPictureInPicture).toBeCalled();
expect(video.requestPictureInPicture).not.toBeCalled();
});
it('does not enter PIP when no video is available', async () => {
const api = createCardAPI();
const manager = new PIPManager(api);
await manager.togglePIP();
expect(api.getCardElementManager().update).not.toBeCalled();
});
});
});
@@ -114,4 +114,10 @@ describe('ImageMediaPlayerController', () => {
expect(controller.getFullscreenElement()).toBeNull();
});
});
it('should return null for getPIPElement', () => {
const controller = new ImageMediaPlayerController(createLitElement(), () => null);
expect(controller.getPIPElement()).toBeNull();
});
});
@@ -249,4 +249,14 @@ describe('JSMPEGMediaPlayerController', () => {
expect(controller.getFullscreenElement()).toBeNull();
});
});
it('should return null for getPIPElement', () => {
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => mock<JSMpeg.VideoElement>(),
() => null,
);
expect(controller.getPIPElement()).toBeNull();
});
});
@@ -189,4 +189,14 @@ describe('UpdatingImageMediaPlayerController', () => {
expect(controller.getFullscreenElement()).toBeNull();
});
});
it('should return null for getPIPElement', () => {
const controller = new UpdatingImageMediaPlayerController(
createLitElement(),
() => null,
() => mock<CachedValueController<string>>(),
);
expect(controller.getPIPElement()).toBeNull();
});
});
@@ -270,4 +270,20 @@ describe('VideoMediaPlayerController', () => {
expect(controller.getFullscreenElement()).toBeNull();
});
});
describe('should get PIP element', () => {
it('should return video element when available', () => {
const video = mock<HTMLVideoElement>();
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
expect(controller.getPIPElement()).toBe(video);
});
it('should return null without video', () => {
const controller = new VideoMediaPlayerController(createLitElement(), () => null);
expect(controller.getPIPElement()).toBeNull();
});
});
});
@@ -8,6 +8,7 @@ import { FoldersManager } from '../../src/card-controller/folders/manager.js';
import { FolderQuery } from '../../src/card-controller/folders/types';
import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager.js';
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager.js';
import { PIPManager } from '../../src/card-controller/pip-manager.js';
import { MicrophoneManager } from '../../src/card-controller/microphone-manager.js';
import { ViewManager } from '../../src/card-controller/view/view-manager.js';
import {
@@ -1484,6 +1485,75 @@ describe('MenuButtonController', () => {
});
});
describe('should have pip button', () => {
it('when not in PIP mode', () => {
const pipManager = mock<PIPManager>();
vi.mocked(pipManager.isInPIP).mockReturnValue(false);
vi.mocked(pipManager.isAvailable).mockReturnValue(true);
const buttons = calculateButtons(controller, { pipManager });
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:picture-in-picture-bottom-right',
enabled: false,
priority: 50,
type: 'custom:advanced-camera-card-menu-icon',
title: 'Picture in Picture',
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'pip',
},
style: {},
});
});
it('when in PIP mode', () => {
const pipManager = mock<PIPManager>();
vi.mocked(pipManager.isInPIP).mockReturnValue(true);
vi.mocked(pipManager.isAvailable).mockReturnValue(true);
const buttons = calculateButtons(controller, { pipManager });
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:picture-in-picture-bottom-right-outline',
enabled: false,
priority: 50,
type: 'custom:advanced-camera-card-menu-icon',
title: 'Picture in Picture',
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'pip',
},
style: { color: 'var(--advanced-camera-card-menu-button-active-color)' },
});
});
it('when not supported', () => {
const pipManager = mock<PIPManager>();
vi.mocked(pipManager.isAvailable).mockReturnValue(false);
const buttons = calculateButtons(controller, { pipManager });
expect(buttons).not.toContainEqual(
expect.objectContaining({ title: 'Picture in Picture' }),
);
});
it('when no pipManager provided', () => {
const buttons = calculateButtons(controller, {});
expect(buttons).not.toContainEqual(
expect.objectContaining({ title: 'Picture in Picture' }),
);
});
});
describe('should have expand button', () => {
it('when not expanded', () => {
const buttons = calculateButtons(controller, { inExpandedMode: false });
+7
View File
@@ -320,6 +320,13 @@ describe('config defaults', () => {
priority: 50,
state_color: true,
},
pip: {
alignment: 'matching',
enabled: false,
permanent: false,
priority: 50,
state_color: true,
},
play: {
alignment: 'matching',
enabled: false,
+2
View File
@@ -46,6 +46,7 @@ import { MediaPlayerManager } from '../src/card-controller/media-player-manager'
import { MessageManager } from '../src/card-controller/message-manager';
import { MicrophoneManager } from '../src/card-controller/microphone-manager';
import { OverlayMessageManager } from '../src/card-controller/overlay-message-manager';
import { PIPManager } from '../src/card-controller/pip-manager';
import { QueryStringManager } from '../src/card-controller/query-string-manager';
import { StatusBarItemManager } from '../src/card-controller/status-bar-item-manager';
import { StyleManager } from '../src/card-controller/style-manager';
@@ -678,6 +679,7 @@ export const createCardAPI = (): CardController => {
api.getMessageManager.mockReturnValue(mock<MessageManager>());
api.getMicrophoneManager.mockReturnValue(mock<MicrophoneManager>());
api.getOverlayMessageManager.mockReturnValue(mock<OverlayMessageManager>());
api.getPIPManager.mockReturnValue(mock<PIPManager>());
api.getQueryStringManager.mockReturnValue(mock<QueryStringManager>());
api.getStatusBarItemManager.mockReturnValue(mock<StatusBarItemManager>());
api.getStyleManager.mockReturnValue(mock<StyleManager>());