diff --git a/projects/core/src/internal/controllers/i18n.controller.test.ts b/projects/core/src/internal/controllers/i18n.controller.test.ts
index d4e443c37..164807a51 100644
--- a/projects/core/src/internal/controllers/i18n.controller.test.ts
+++ b/projects/core/src/internal/controllers/i18n.controller.test.ts
@@ -79,7 +79,9 @@ describe('i18n.controller', () => {
enableLoop: 'enable looping',
disableLoop: 'disable looping',
enterFullscreen: 'enter full screen',
+ enterPip: 'enter picture in picture',
exitFullscreen: 'exit full screen',
+ exitPip: 'exit picture in picture',
muteMedia: 'mute media',
pauseMedia: 'pause media',
playMedia: 'play media',
@@ -152,7 +154,9 @@ describe('i18n.controller', () => {
enableLoop: 'enable looping',
disableLoop: 'disable looping',
enterFullscreen: 'enter full screen',
+ enterPip: 'enter picture in picture',
exitFullscreen: 'exit full screen',
+ exitPip: 'exit picture in picture',
muteMedia: 'mute media',
pauseMedia: 'pause media',
playMedia: 'play media',
diff --git a/projects/core/src/internal/services/i18n.service.test.ts b/projects/core/src/internal/services/i18n.service.test.ts
index f5105832b..eec1bb720 100644
--- a/projects/core/src/internal/services/i18n.service.test.ts
+++ b/projects/core/src/internal/services/i18n.service.test.ts
@@ -53,7 +53,9 @@ describe('I18nService', () => {
enableLoop: 'enable looping',
disableLoop: 'disable looping',
enterFullscreen: 'enter full screen',
+ enterPip: 'enter picture in picture',
exitFullscreen: 'exit full screen',
+ exitPip: 'exit picture in picture',
muteMedia: 'mute media',
pauseMedia: 'pause media',
playMedia: 'play media',
diff --git a/projects/core/src/internal/services/i18n.service.ts b/projects/core/src/internal/services/i18n.service.ts
index d666d8b95..9a164aff6 100644
--- a/projects/core/src/internal/services/i18n.service.ts
+++ b/projects/core/src/internal/services/i18n.service.ts
@@ -45,7 +45,9 @@ export interface I18nStrings {
enableLoop: string;
disableLoop: string;
enterFullscreen: string;
+ enterPip: string;
exitFullscreen: string;
+ exitPip: string;
muteMedia: string;
pauseMedia: string;
playMedia: string;
@@ -101,7 +103,9 @@ const i18nRegistry = {
enableLoop: 'enable looping',
disableLoop: 'disable looping',
enterFullscreen: 'enter full screen',
+ enterPip: 'enter picture in picture',
exitFullscreen: 'exit full screen',
+ exitPip: 'exit picture in picture',
muteMedia: 'mute media',
pauseMedia: 'pause media',
playMedia: 'play media',
diff --git a/projects/core/src/star-rating/star-rating.test.lighthouse.ts b/projects/core/src/star-rating/star-rating.test.lighthouse.ts
index b429d543d..1623be6bc 100644
--- a/projects/core/src/star-rating/star-rating.test.lighthouse.ts
+++ b/projects/core/src/star-rating/star-rating.test.lighthouse.ts
@@ -23,6 +23,6 @@ describe('star-rating lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.kb).toBeLessThan(22.4);
+ expect(report.payload.javascript.kb).toBeLessThan(22.5);
});
});
diff --git a/projects/core/src/tree/tree.test.lighthouse.ts b/projects/core/src/tree/tree.test.lighthouse.ts
index 4933ac14d..afa5b87aa 100644
--- a/projects/core/src/tree/tree.test.lighthouse.ts
+++ b/projects/core/src/tree/tree.test.lighthouse.ts
@@ -30,7 +30,6 @@ describe('tree lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
- // Allow a small margin above measured local and CI payloads.
- expect(report.payload.javascript.kb).toBeLessThanOrEqual(30.6);
+ expect(report.payload.javascript.kb).toBeLessThan(30.7);
});
});
diff --git a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts
index 95d04186b..e295dfcb2 100644
--- a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts
+++ b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts
@@ -10,7 +10,7 @@ import noInvalidInvokerTriggers from './no-invalid-invoker-triggers.js';
const rule = noInvalidInvokerTriggers as unknown as JSRuleDefinition;
const validElements =
- 'nve-button, nve-icon-button, nve-menu-item, nve-sort-button, nve-tabs-item, nve-tag, nve-steps-item, nve-copy-button, nve-media-fullscreen-button, nve-media-loop-button, nve-media-mute-button, nve-media-pause-button, nve-media-seek-button';
+ 'nve-button, nve-icon-button, nve-menu-item, nve-sort-button, nve-tabs-item, nve-tag, nve-steps-item, nve-copy-button, nve-media-fullscreen-button, nve-media-loop-button, nve-media-mute-button, nve-media-pause-button, nve-media-pip-button, nve-media-seek-button';
const commandElements = `${validElements}, nve-media-playback-rate-select, nve-media-time-range, nve-media-volume-range, nve-viewport-minimap, nve-viewport-zoom-range`;
@@ -53,7 +53,8 @@ describe('noInvalidInvokerTriggers', () => {
'Tag',
'Step',
'',
- ''
+ '',
+ ''
],
invalid: []
});
@@ -66,6 +67,7 @@ describe('noInvalidInvokerTriggers', () => {
'',
'Tag',
'',
+ '',
'',
'',
'',
@@ -84,7 +86,8 @@ describe('noInvalidInvokerTriggers', () => {
tester.run('interestfor on button-type elements', rule, {
valid: [
'Hover',
- ''
+ '',
+ ''
],
invalid: []
});
diff --git a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts
index 9e7ab5d93..eea8b1d46 100644
--- a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts
+++ b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts
@@ -29,6 +29,7 @@ const BUTTON_TYPE_ELEMENTS = [
'nve-media-loop-button',
'nve-media-mute-button',
'nve-media-pause-button',
+ 'nve-media-pip-button',
'nve-media-seek-button'
] as const;
diff --git a/projects/media/.visual/media-pip-button.dark.png b/projects/media/.visual/media-pip-button.dark.png
new file mode 100644
index 000000000..3d8358cd4
--- /dev/null
+++ b/projects/media/.visual/media-pip-button.dark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:79f3360bd027143edf8bd302c5e3e67ac8654c07e4c7fad623ae0a82fd4f7a6d
+size 1420
diff --git a/projects/media/.visual/media-pip-button.png b/projects/media/.visual/media-pip-button.png
new file mode 100644
index 000000000..3e5785d71
--- /dev/null
+++ b/projects/media/.visual/media-pip-button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0dd50153793c1c926a6edf70fdb810399b2e96513a2d8cde9ca9da27881622e0
+size 1383
diff --git a/projects/media/package.json b/projects/media/package.json
index 4b98cb028..65d2fcd5e 100644
--- a/projects/media/package.json
+++ b/projects/media/package.json
@@ -113,6 +113,18 @@
"types": "./dist/pause-button/index.d.ts",
"default": "./dist/pause-button/index.js"
},
+ "./pip-button": {
+ "types": "./dist/pip-button/index.d.ts",
+ "default": "./dist/pip-button/index.js"
+ },
+ "./pip-button/define.js": {
+ "types": "./dist/pip-button/define.d.ts",
+ "default": "./dist/pip-button/define.js"
+ },
+ "./pip-button/index.js": {
+ "types": "./dist/pip-button/index.d.ts",
+ "default": "./dist/pip-button/index.js"
+ },
"./playback-rate-select": {
"types": "./dist/playback-rate-select/index.d.ts",
"default": "./dist/playback-rate-select/index.js"
diff --git a/projects/media/src/controller/controller.examples.ts b/projects/media/src/controller/controller.examples.ts
index 74e65db66..dc25657ad 100644
--- a/projects/media/src/controller/controller.examples.ts
+++ b/projects/media/src/controller/controller.examples.ts
@@ -28,6 +28,7 @@ export const Default = {
+
diff --git a/projects/media/src/controller/controller.pip.test.ts b/projects/media/src/controller/controller.pip.test.ts
new file mode 100644
index 000000000..acb95b0f2
--- /dev/null
+++ b/projects/media/src/controller/controller.pip.test.ts
@@ -0,0 +1,319 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createFixture, elementIsStable, emulateClick, removeFixture } from '@internals/testing';
+import { mediaCommands } from '../internal/media-command.js';
+import { isMediaState } from '../internal/media-state.js';
+import type { MediaPipButton } from '../pip-button/pip-button.js';
+import { MediaController } from './controller.js';
+import '../pip-button/define.js';
+import '../mute-button/define.js';
+import './define.js';
+
+describe(MediaController.metadata.tag + ' picture-in-picture', () => {
+ let fixture: HTMLElement;
+ let controller: MediaController;
+ let video: HTMLVideoElement;
+ let button: MediaPipButton;
+ let active: Element | null;
+ let request: ReturnType Promise>>;
+ let exit: ReturnType Promise>>;
+
+ beforeEach(async () => {
+ active = null;
+ vi.spyOn(document, 'pictureInPictureEnabled', 'get').mockReturnValue(true);
+ vi.spyOn(document, 'pictureInPictureElement', 'get').mockImplementation(() => active);
+ fixture = await createFixture(html`
+
+
+
+
+ `);
+ controller = getElement(fixture, MediaController.metadata.tag);
+ video = getElement(fixture, 'video');
+ button = getElement(fixture, 'nve-media-pip-button');
+ setupVideo(video);
+ request = vi.spyOn(video, 'requestPictureInPicture').mockImplementation(async () => {
+ active = video;
+ video.dispatchEvent(new Event('enterpictureinpicture'));
+ return createPipWindow();
+ });
+ exit = vi.spyOn(document, 'exitPictureInPicture').mockImplementation(async () => {
+ const previous = active;
+ active = null;
+ previous?.dispatchEvent(new Event('leavepictureinpicture'));
+ });
+ video.dispatchEvent(new Event('loadedmetadata'));
+ await elementIsStable(controller);
+ await elementIsStable(button);
+ });
+
+ afterEach(() => {
+ removeFixture(fixture);
+ vi.restoreAllMocks();
+ });
+
+ it('should toggle video PiP through the button and mirror confirmed state', async () => {
+ expect(controller.mediaState.pipAvailable).toBe(true);
+ expect(controller.hasAttribute('pip-available')).toBe(true);
+ await emulateClick(button);
+ await vi.waitFor(() => expect(button.pressed).toBe(true));
+ expect(request).toHaveBeenCalledOnce();
+ expect(controller.mediaState.pip).toBe(true);
+ expect(controller.hasAttribute('pip')).toBe(true);
+ expect(Object.isFrozen(controller.mediaState)).toBe(true);
+
+ await emulateClick(button);
+ await vi.waitFor(() => expect(button.pressed).toBe(false));
+ expect(exit).toHaveBeenCalledOnce();
+ expect(controller.hasAttribute('pip')).toBe(false);
+ });
+
+ it('should keep media controls synchronized when the PiP enabled API is absent', async () => {
+ Object.defineProperty(document, 'pictureInPictureEnabled', { value: undefined, configurable: true });
+ const muteButton = document.createElement('nve-media-mute-button');
+ muteButton.setAttribute('commandfor', 'pip-controller');
+ controller.append(muteButton);
+ await elementIsStable(muteButton);
+
+ video.muted = true;
+ video.dispatchEvent(new Event('volumechange'));
+ await elementIsStable(muteButton);
+ await elementIsStable(button);
+
+ expect(controller.mediaState.pipAvailable).toBe(false);
+ expect(isMediaState(controller.mediaState)).toBe(true);
+ expect(button.disabled).toBe(true);
+ expect(muteButton.checked).toBe(true);
+ });
+
+ it('should support explicit enter and exit commands without altering playback', async () => {
+ const play = vi.spyOn(video, 'play');
+ const pause = vi.spyOn(video, 'pause');
+ dispatchCommand(controller, mediaCommands.enterPip);
+ await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
+ await Promise.resolve();
+ await Promise.resolve();
+ dispatchCommand(controller, mediaCommands.enterPip);
+ expect(request).toHaveBeenCalledOnce();
+ dispatchCommand(controller, mediaCommands.exitPip);
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledOnce());
+ expect(play).not.toHaveBeenCalled();
+ expect(pause).not.toHaveBeenCalled();
+ });
+
+ it('should sync native entry and window closing events', async () => {
+ active = video;
+ video.dispatchEvent(new Event('enterpictureinpicture'));
+ await elementIsStable(button);
+ expect(button.pressed).toBe(true);
+ active = null;
+ video.dispatchEvent(new Event('leavepictureinpicture'));
+ await elementIsStable(button);
+ expect(button.pressed).toBe(false);
+ });
+
+ it('should only exit its own video and request entry when another video owns PiP', async () => {
+ active = document.createElement('video');
+ dispatchCommand(controller, mediaCommands.exitPip);
+ expect(exit).not.toHaveBeenCalled();
+ dispatchCommand(controller, mediaCommands.togglePip);
+ expect(request).toHaveBeenCalledOnce();
+ expect(exit).not.toHaveBeenCalled();
+ });
+
+ it.each(['policy disabled', 'API missing', 'exit API missing', 'metadata unloaded', 'no video track'])(
+ 'should disable entry when %s',
+ async reason => {
+ const unavailable: Record void> = {
+ 'policy disabled': () => {
+ vi.spyOn(document, 'pictureInPictureEnabled', 'get').mockReturnValue(false);
+ },
+ 'API missing': () => {
+ Object.defineProperty(video, 'requestPictureInPicture', { value: undefined, configurable: true });
+ },
+ 'exit API missing': () => {
+ Object.defineProperty(document, 'exitPictureInPicture', { value: undefined, configurable: true });
+ },
+ 'metadata unloaded': () => {
+ vi.spyOn(video, 'readyState', 'get').mockReturnValue(0);
+ },
+ 'no video track': () => {
+ vi.spyOn(video, 'videoWidth', 'get').mockReturnValue(0);
+ }
+ };
+ unavailable[reason]?.();
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ video.dispatchEvent(new Event('emptied'));
+ await elementIsStable(button);
+ expect(controller.mediaState.pipAvailable).toBe(false);
+ expect(button.disabled).toBe(true);
+ dispatchCommand(controller, mediaCommands.enterPip);
+ expect(request).not.toHaveBeenCalled();
+ expect(warn).toHaveBeenCalledWith('nve-media-controller picture-in-picture unavailable');
+ }
+ );
+
+ it('should observe native disabling and retain the ability to exit active PiP', async () => {
+ video.disablePictureInPicture = true;
+ await vi.waitFor(() => expect(button.disabled).toBe(true));
+ expect(controller.mediaState.pipAvailable).toBe(false);
+ video.disablePictureInPicture = false;
+ await vi.waitFor(() => expect(button.disabled).toBe(false));
+ active = video;
+ video.dispatchEvent(new Event('enterpictureinpicture'));
+ video.disablePictureInPicture = true;
+ await elementIsStable(button);
+ expect(button.disabled).toBe(false);
+ dispatchCommand(controller, mediaCommands.exitPip);
+ expect(exit).toHaveBeenCalledOnce();
+ });
+
+ it('should handle audio and absent media without PiP methods', async () => {
+ const audio = document.createElement('audio');
+ video.replaceWith(audio);
+ await vi.waitFor(() => expect(controller.mediaState.pipAvailable).toBe(false));
+ dispatchCommand(controller, mediaCommands.enterPip);
+ dispatchCommand(controller, mediaCommands.exitPip);
+ expect(request).not.toHaveBeenCalled();
+ audio.remove();
+ await elementIsStable(controller);
+ expect(controller.mediaState.pip).toBe(false);
+ expect(controller.mediaState.pipAvailable).toBe(false);
+ });
+
+ it.each(['enter', 'exit'])('should warn on rejected %s requests without optimistic state changes', async action => {
+ const error = new DOMException('denied', 'NotAllowedError');
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ if (action === 'enter') {
+ request.mockRejectedValue(error);
+ dispatchCommand(controller, mediaCommands.enterPip);
+ } else {
+ active = video;
+ video.dispatchEvent(new Event('enterpictureinpicture'));
+ exit.mockRejectedValue(error);
+ dispatchCommand(controller, mediaCommands.exitPip);
+ }
+ await vi.waitFor(() => expect(warn).toHaveBeenCalled());
+ expect(warn).toHaveBeenCalledWith(
+ action === 'enter'
+ ? 'nve-media-controller picture-in-picture failed'
+ : 'nve-media-controller picture-in-picture exit failed',
+ error
+ );
+ expect(controller.mediaState.pip).toBe(action === 'exit');
+ });
+
+ it('should recover from synchronous API exceptions', () => {
+ const error = new Error('synchronous failure');
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ request.mockImplementationOnce(() => {
+ throw error;
+ });
+ dispatchCommand(controller, mediaCommands.enterPip);
+ expect(warn).toHaveBeenCalledWith('nve-media-controller picture-in-picture failed', error);
+ dispatchCommand(controller, mediaCommands.enterPip);
+ expect(request).toHaveBeenCalledTimes(2);
+ });
+
+ it('should ignore duplicate commands while entry is pending and resync after completion', async () => {
+ let resolve: ((value: PictureInPictureWindow) => void) | undefined;
+ request.mockImplementationOnce(
+ () =>
+ new Promise(result => {
+ resolve = result;
+ })
+ );
+ dispatchCommand(controller, mediaCommands.enterPip);
+ dispatchCommand(controller, mediaCommands.togglePip);
+ dispatchCommand(controller, mediaCommands.exitPip);
+ expect(request).toHaveBeenCalledOnce();
+ expect(controller.mediaState.pip).toBe(false);
+ active = video;
+ resolve?.(createPipWindow());
+ await vi.waitFor(() => expect(controller.mediaState.pip).toBe(true));
+ });
+
+ it('should detach observers and prevent stale completion after replacing the video', async () => {
+ let resolve: ((value: PictureInPictureWindow) => void) | undefined;
+ request.mockImplementationOnce(
+ () =>
+ new Promise(result => {
+ resolve = result;
+ })
+ );
+ dispatchCommand(controller, mediaCommands.enterPip);
+ const replacement = document.createElement('video');
+ video.replaceWith(replacement);
+ await vi.waitFor(() => expect(controller.mediaState.pipAvailable).toBe(false));
+ const snapshot = controller.mediaState;
+ active = video;
+ video.dispatchEvent(new Event('enterpictureinpicture'));
+ video.disablePictureInPicture = true;
+ resolve?.(createPipWindow());
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(controller.mediaState).toBe(snapshot);
+ expect(controller.mediaState.pip).toBe(false);
+ });
+
+ it('should sync existing PiP when reconnecting and ignore events while disconnected', async () => {
+ controller.remove();
+ const snapshot = controller.mediaState;
+ active = video;
+ video.dispatchEvent(new Event('enterpictureinpicture'));
+ expect(controller.mediaState).toBe(snapshot);
+ fixture.append(controller);
+ await elementIsStable(controller);
+ await elementIsStable(button);
+ expect(controller.mediaState.pip).toBe(true);
+ expect(button.pressed).toBe(true);
+ });
+
+ it('should read the video shadow root rather than a retargeted document element', async () => {
+ const host = document.createElement('div');
+ fixture.append(host);
+ const root = host.attachShadow({ mode: 'open' });
+ root.append(controller);
+ vi.spyOn(root, 'pictureInPictureElement', 'get').mockImplementation(() => active);
+ active = video;
+ vi.spyOn(document, 'pictureInPictureElement', 'get').mockReturnValue(host);
+ video.dispatchEvent(new Event('enterpictureinpicture'));
+ await elementIsStable(controller);
+ expect(controller.mediaState.pip).toBe(true);
+ dispatchCommand(controller, mediaCommands.exitPip);
+ expect(exit).toHaveBeenCalledOnce();
+ });
+
+ it('should avoid redundant state events and react to video resize', () => {
+ const change = vi.fn();
+ controller.addEventListener('media-state-change', change);
+ video.dispatchEvent(new Event('loadedmetadata'));
+ expect(change).not.toHaveBeenCalled();
+ vi.spyOn(video, 'videoWidth', 'get').mockReturnValue(0);
+ video.dispatchEvent(new Event('resize'));
+ expect(change).toHaveBeenCalledOnce();
+ expect(controller.mediaState.pipAvailable).toBe(false);
+ });
+});
+
+function setupVideo(video: HTMLVideoElement) {
+ vi.spyOn(video, 'readyState', 'get').mockReturnValue(HTMLMediaElement.HAVE_METADATA);
+ vi.spyOn(video, 'videoWidth', 'get').mockReturnValue(640);
+}
+
+function dispatchCommand(controller: MediaController, command: string) {
+ controller.dispatchEvent(new CommandEvent('command', { command }));
+}
+
+function getElement(root: ParentNode, selector: string) {
+ const element = root.querySelector(selector);
+ expect(element).toBeTruthy();
+ return element as T;
+}
+
+function createPipWindow(): PictureInPictureWindow {
+ return Object.assign(new EventTarget(), { width: 640, height: 360, onresize: null });
+}
diff --git a/projects/media/src/controller/controller.test.lighthouse.ts b/projects/media/src/controller/controller.test.lighthouse.ts
index f7c34d6dd..cde40e7fe 100644
--- a/projects/media/src/controller/controller.test.lighthouse.ts
+++ b/projects/media/src/controller/controller.test.lighthouse.ts
@@ -18,6 +18,6 @@ describe('media controller lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.kb).toBeLessThan(11.5);
+ expect(report.payload.javascript.kb).toBeLessThan(12);
});
});
diff --git a/projects/media/src/controller/controller.ts b/projects/media/src/controller/controller.ts
index f0d478d89..d85e244c8 100644
--- a/projects/media/src/controller/controller.ts
+++ b/projects/media/src/controller/controller.ts
@@ -18,11 +18,14 @@ const mediaEventTypes = [
'durationchange',
'emptied',
'ended',
+ 'enterpictureinpicture',
+ 'leavepictureinpicture',
'loadedmetadata',
'pause',
'play',
'progress',
'ratechange',
+ 'resize',
'seeked',
'seeking',
'timeupdate',
@@ -54,6 +57,9 @@ const mediaEventTypes = [
* @command --enter-fullscreen - Request full-screen mode on the controller.
* @command --exit-fullscreen - Exit full-screen mode.
* @command --toggle-fullscreen - Toggle full-screen mode.
+ * @command --enter-pip - Request picture-in-picture mode on the video.
+ * @command --exit-pip - Exit picture-in-picture mode for the video.
+ * @command --toggle-pip - Toggle picture-in-picture mode for the video.
* @property mediaState - The latest immutable media and full-screen state snapshot.
* @event media-state-change - Dispatched with the complete state snapshot when the controller media state changes.
* @slot - The controlled video or audio element and supporting controls or content.
@@ -79,6 +85,10 @@ export class MediaController extends LitElement {
#mediaState = createMediaState();
+ #pipObserver: MutationObserver | null = null;
+
+ #pipOperation: symbol | null = null;
+
#commandHandlers = new Map void>([
[mediaCommands.play, () => this.#play()],
[mediaCommands.pause, () => this.#pause()],
@@ -98,7 +108,10 @@ export class MediaController extends LitElement {
[mediaCommands.setPlaybackRate, source => this.#setPlaybackRateFromSource(source)],
[mediaCommands.enterFullscreen, () => this.#enterFullscreen()],
[mediaCommands.exitFullscreen, () => this.#exitFullscreen()],
- [mediaCommands.toggleFullscreen, () => this.#toggleFullscreen()]
+ [mediaCommands.toggleFullscreen, () => this.#toggleFullscreen()],
+ [mediaCommands.enterPip, () => this.#enterPip()],
+ [mediaCommands.exitPip, () => this.#exitPip()],
+ [mediaCommands.togglePip, () => this.#togglePip()]
]);
render() {
@@ -168,18 +181,31 @@ export class MediaController extends LitElement {
}
this.#media && mediaEventTypes.forEach(type => this.#media?.removeEventListener(type, this.#syncMediaState));
- this.#loopObserver?.disconnect();
+ this.#observeLoop(media);
+ this.#pipObserver?.disconnect();
+ this.#pipObserver = null;
+ this.#pipOperation = null;
this.#media = media;
this.#media && mediaEventTypes.forEach(type => this.#media?.addEventListener(type, this.#syncMediaState));
+ if (isVideoElement(media)) {
+ this.#pipObserver = new MutationObserver(this.#syncMediaState);
+ this.#pipObserver.observe(media, { attributes: true, attributeFilter: ['disablepictureinpicture'] });
+ }
+ this.#syncMediaState();
+ }
+
+ #observeLoop(media: HTMLMediaElement | null) {
+ this.#loopObserver?.disconnect();
if (media) {
this.#loopObserver ??= new MutationObserver(this.#syncMediaState);
this.#loopObserver.observe(media, { attributes: true, attributeFilter: ['loop'] });
}
- this.#syncMediaState();
}
#syncMediaState = () => {
- this.#setMediaState(createMediaState({ ...getMediaState(this.#media), fullscreen: this.#fullscreen }));
+ this.#setMediaState(
+ createMediaState({ ...getMediaState(this.#media), ...getPipState(this.#media), fullscreen: this.#fullscreen })
+ );
};
#syncFullscreen = () => {
@@ -193,12 +219,10 @@ export class MediaController extends LitElement {
return;
}
- setBooleanAttribute(this, 'paused', state.paused);
- setBooleanAttribute(this, 'muted', state.muted);
- setBooleanAttribute(this, 'ended', state.ended);
- setBooleanAttribute(this, 'seeking', state.seeking);
- setBooleanAttribute(this, 'fullscreen', state.fullscreen);
- setBooleanAttribute(this, 'loop', state.loop);
+ for (const attribute of ['paused', 'muted', 'ended', 'seeking', 'fullscreen', 'loop', 'pip'] as const) {
+ setBooleanAttribute(this, attribute, state[attribute]);
+ }
+ setBooleanAttribute(this, 'pip-available', state.pipAvailable);
setNumberAttribute(this, 'current-time', state.currentTime);
setNumberAttribute(this, 'duration', state.duration);
setNumberAttribute(this, 'volume', state.volume);
@@ -334,6 +358,52 @@ export class MediaController extends LitElement {
this.#fullscreen ? this.#exitFullscreen() : this.#enterFullscreen();
}
+ #enterPip() {
+ const video = this.#media;
+ if (this.#pipOperation || !isVideoElement(video) || getPipState(video).pip) {
+ return;
+ }
+ if (!getPipState(video).pipAvailable) {
+ this.#syncMediaState();
+ console.warn('nve-media-controller picture-in-picture unavailable');
+ return;
+ }
+ this.#runPipOperation(video, () => video.requestPictureInPicture(), 'picture-in-picture failed');
+ }
+
+ #exitPip() {
+ const video = this.#media;
+ if (this.#pipOperation || !isVideoElement(video) || !getPipState(video).pip) {
+ return;
+ }
+ this.#runPipOperation(video, () => video.ownerDocument.exitPictureInPicture(), 'picture-in-picture exit failed');
+ }
+
+ #togglePip() {
+ getPipState(this.#media).pip ? this.#exitPip() : this.#enterPip();
+ }
+
+ #runPipOperation(video: HTMLVideoElement, operation: () => Promise, warning: string) {
+ const token = Symbol();
+ this.#pipOperation = token;
+ const complete = () => {
+ if (this.#pipOperation === token) {
+ this.#pipOperation = null;
+ if (this.isConnected && video === this.#media) {
+ this.#syncMediaState();
+ }
+ }
+ };
+ try {
+ void operation()
+ .catch(error => console.warn(`nve-media-controller ${warning}`, error))
+ .finally(complete);
+ } catch (error) {
+ console.warn(`nve-media-controller ${warning}`, error);
+ complete();
+ }
+ }
+
#getMedia() {
if (!this.#media) {
console.warn('nve-media-controller missing media element');
@@ -350,6 +420,29 @@ function isSupportedMediaElement(element: Element): element is HTMLMediaElement
return typeof HTMLMediaElement !== 'undefined' && element instanceof HTMLMediaElement;
}
+function isVideoElement(media: HTMLMediaElement | null): media is HTMLVideoElement {
+ return typeof globalThis.HTMLVideoElement !== 'undefined' && media instanceof globalThis.HTMLVideoElement;
+}
+
+function getPipState(media: HTMLMediaElement | null) {
+ if (!isVideoElement(media)) {
+ return { pip: false, pipAvailable: false };
+ }
+ const document = media.ownerDocument;
+ const root = media.getRootNode();
+ const scope = root instanceof ShadowRoot ? root : document;
+ return {
+ pip: scope.pictureInPictureElement === media,
+ pipAvailable:
+ (document.pictureInPictureEnabled ?? false) &&
+ typeof media.requestPictureInPicture === 'function' &&
+ typeof document.exitPictureInPicture === 'function' &&
+ media.readyState > HTMLMediaElement.HAVE_NOTHING &&
+ media.videoWidth > 0 &&
+ !media.disablePictureInPicture
+ };
+}
+
function getMediaState(media: HTMLMediaElement | null) {
if (!media) {
return {
diff --git a/projects/media/src/internal/button-form-control-usage.test.ts b/projects/media/src/internal/button-form-control-usage.test.ts
index 7fa6937ea..f9e717c9a 100644
--- a/projects/media/src/internal/button-form-control-usage.test.ts
+++ b/projects/media/src/internal/button-form-control-usage.test.ts
@@ -5,6 +5,9 @@ import { html, type TemplateResult } from 'lit';
import { afterEach, describe, expect, it } from 'vitest';
import { createFixture, elementIsStable, emulateClick, removeFixture, untilEvent } from '@internals/testing';
import type { ButtonFormControlMixinInstance } from '@nvidia-elements/forms/mixins';
+import { createMediaState } from './media-state.js';
+import { MediaPipButton } from '../pip-button/pip-button.js';
+import '../pip-button/define.js';
import { MediaFullscreenButton } from '../fullscreen-button/fullscreen-button.js';
import { MediaLoopButton } from '../loop-button/loop-button.js';
import { MediaMuteButton } from '../mute-button/mute-button.js';
@@ -27,6 +30,11 @@ type ButtonElement = HTMLElement & ButtonFormControlMixinInstance & { _internals
type CheckboxButtonElement = ButtonElement & { checked: boolean };
const usages: ButtonUsage[] = [
+ {
+ tag: MediaPipButton.metadata.tag,
+ template: html``,
+ submitTemplate: html``
+ },
{
tag: MediaFullscreenButton.metadata.tag,
template: html``,
@@ -162,7 +170,7 @@ describe('ButtonFormControlMixin media usage', () => {
async function createButton({ template, tag }: ButtonUsage) {
fixture = await createFixture(html`
${template}
-
+
`);
const button = getElement(fixture, tag);
await elementIsStable(button);
@@ -174,7 +182,7 @@ describe('ButtonFormControlMixin media usage', () => {
-
+
`);
const button = getElement(fixture, tag);
await elementIsStable(button);
diff --git a/projects/media/src/internal/media-button.css b/projects/media/src/internal/media-button.css
index 35bcc14d3..e2236d4fb 100644
--- a/projects/media/src/internal/media-button.css
+++ b/projects/media/src/internal/media-button.css
@@ -70,7 +70,7 @@
--color: var(--nve-sys-interaction-selected-color);
}
-:host(:hover:not([disabled], [readonly], [pressed])) {
+:host(:hover:not(:state(disabled), [readonly], [pressed])) {
--color: var(--nve-sys-interaction-hover-color);
}
diff --git a/projects/media/src/internal/media-command.ts b/projects/media/src/internal/media-command.ts
index 190aa289b..296392a38 100644
--- a/projects/media/src/internal/media-command.ts
+++ b/projects/media/src/internal/media-command.ts
@@ -20,7 +20,10 @@ export const mediaCommands = {
setPlaybackRate: '--set-playback-rate',
enterFullscreen: '--enter-fullscreen',
exitFullscreen: '--exit-fullscreen',
- toggleFullscreen: '--toggle-fullscreen'
+ toggleFullscreen: '--toggle-fullscreen',
+ enterPip: '--enter-pip',
+ exitPip: '--exit-pip',
+ togglePip: '--toggle-pip'
} as const;
export type MediaCommand = (typeof mediaCommands)[keyof typeof mediaCommands];
diff --git a/projects/media/src/internal/media-state.test.ts b/projects/media/src/internal/media-state.test.ts
index 9c9db1143..62016bc11 100644
--- a/projects/media/src/internal/media-state.test.ts
+++ b/projects/media/src/internal/media-state.test.ts
@@ -30,6 +30,12 @@ describe(isMediaState.name, () => {
expect(isMediaState(createMediaState({ loop: true }))).toBe(true);
});
+ it('should validate PiP state and availability as booleans', () => {
+ expect(isMediaState({ ...createMediaState(), pip: 'true' })).toBe(false);
+ expect(isMediaState({ ...createMediaState(), pipAvailable: undefined })).toBe(false);
+ expect(isMediaState(createMediaState({ pip: true, pipAvailable: true }))).toBe(true);
+ });
+
it('should validate ordered and disjoint buffered time spans', () => {
expect(
isMediaState(
@@ -76,6 +82,14 @@ describe(mediaStatesEqual.name, () => {
expect(mediaStatesEqual(createMediaState({ loop: true }), createMediaState({ loop: true }))).toBe(true);
});
+ it('should compare PiP state and availability', () => {
+ const state = createMediaState();
+ expect(state.pip).toBe(false);
+ expect(state.pipAvailable).toBe(false);
+ expect(mediaStatesEqual(state, createMediaState({ pip: true }))).toBe(false);
+ expect(mediaStatesEqual(state, createMediaState({ pipAvailable: true }))).toBe(false);
+ });
+
it('should compare buffered time spans by value', () => {
const state = createMediaState({
buffered: [
diff --git a/projects/media/src/internal/media-state.ts b/projects/media/src/internal/media-state.ts
index 27001590f..1f4f6e09a 100644
--- a/projects/media/src/internal/media-state.ts
+++ b/projects/media/src/internal/media-state.ts
@@ -17,6 +17,8 @@ export interface MediaState {
readonly loop: boolean;
readonly muted: boolean;
readonly paused: boolean;
+ readonly pip: boolean;
+ readonly pipAvailable: boolean;
readonly playbackRate: number;
readonly seeking: boolean;
readonly volume: number;
@@ -26,7 +28,7 @@ export type MediaStateChangeEvent = CustomEvent;
export const mediaStateChange = 'media-state-change';
-const booleanStateKeys = ['ended', 'fullscreen', 'loop', 'muted', 'paused', 'seeking'] as const;
+const booleanStateKeys = ['ended', 'fullscreen', 'loop', 'muted', 'paused', 'pip', 'pipAvailable', 'seeking'] as const;
const numberStateKeys = ['currentTime', 'duration', 'playbackRate', 'volume'] as const;
@@ -41,6 +43,8 @@ const defaultMediaState: MediaState = {
loop: false,
muted: false,
paused: true,
+ pip: false,
+ pipAvailable: false,
playbackRate: 1,
seeking: false,
volume: 1
diff --git a/projects/media/src/pip-button/define.ts b/projects/media/src/pip-button/define.ts
new file mode 100644
index 000000000..c09c8a860
--- /dev/null
+++ b/projects/media/src/pip-button/define.ts
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { define } from '@nvidia-elements/core/internal';
+import { MediaPipButton } from '@nvidia-elements/media/pip-button';
+
+define(MediaPipButton);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'nve-media-pip-button': MediaPipButton;
+ }
+}
diff --git a/projects/media/src/pip-button/index.ts b/projects/media/src/pip-button/index.ts
new file mode 100644
index 000000000..06d4f1e86
--- /dev/null
+++ b/projects/media/src/pip-button/index.ts
@@ -0,0 +1,4 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export * from './pip-button.js';
diff --git a/projects/media/src/pip-button/pip-button.css b/projects/media/src/pip-button/pip-button.css
new file mode 100644
index 000000000..4fc0cb99e
--- /dev/null
+++ b/projects/media/src/pip-button/pip-button.css
@@ -0,0 +1,2 @@
+/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */
+/* SPDX-License-Identifier: Apache-2.0 */
diff --git a/projects/media/src/pip-button/pip-button.examples.ts b/projects/media/src/pip-button/pip-button.examples.ts
new file mode 100644
index 000000000..0355cdce4
--- /dev/null
+++ b/projects/media/src/pip-button/pip-button.examples.ts
@@ -0,0 +1,21 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+
+export default {
+ title: 'Media/PipButton',
+ component: 'nve-media-pip-button'
+};
+
+/**
+ * @summary Picture-in-picture button connected to a video controller. Use this control to keep a video visible while working in another window.
+ */
+export const Default = {
+ render: () => html`
+
+
+
+
+ `
+};
diff --git a/projects/media/src/pip-button/pip-button.test.axe.ts b/projects/media/src/pip-button/pip-button.test.axe.ts
new file mode 100644
index 000000000..bc80a7528
--- /dev/null
+++ b/projects/media/src/pip-button/pip-button.test.axe.ts
@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+import { describe, expect, it } from 'vitest';
+import { createFixture, elementIsStable, removeFixture } from '@internals/testing';
+import { runAxe } from '@internals/testing/axe';
+import { createMediaState } from '../internal/media-state.js';
+import { MediaPipButton } from './pip-button.js';
+import './define.js';
+
+describe(MediaPipButton.metadata.tag, () => {
+ it('should pass axe checks for inactive, active, unavailable, and consumer-disabled controls', async () => {
+ const fixture = await createFixture(html`
+
+
+
+
+
+
+ `);
+ try {
+ await Promise.all(
+ [...fixture.querySelectorAll(MediaPipButton.metadata.tag)].map(element =>
+ elementIsStable(element)
+ )
+ );
+ const results = await runAxe([MediaPipButton.metadata.tag]);
+ expect(results).toMatchObject({ violations: [] });
+ } finally {
+ removeFixture(fixture);
+ }
+ });
+});
diff --git a/projects/media/src/pip-button/pip-button.test.lighthouse.ts b/projects/media/src/pip-button/pip-button.test.lighthouse.ts
new file mode 100644
index 000000000..0f5aff404
--- /dev/null
+++ b/projects/media/src/pip-button/pip-button.test.lighthouse.ts
@@ -0,0 +1,21 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, test } from 'vitest';
+import { lighthouseRunner } from '@internals/vite';
+
+describe('media pip button lighthouse report', () => {
+ test('media pip button should meet lighthouse benchmarks', async () => {
+ const report = await lighthouseRunner.getReport('nve-media-pip-button', /* html */`
+
+
+ `);
+
+ expect(report.scores.performance).toBe(100);
+ expect(report.scores.accessibility).toBe(100);
+ expect(report.scores.bestPractices).toBe(100);
+ expect(report.payload.javascript.kb).toBeLessThan(21.5);
+ });
+});
diff --git a/projects/media/src/pip-button/pip-button.test.ssr.ts b/projects/media/src/pip-button/pip-button.test.ssr.ts
new file mode 100644
index 000000000..183d1819d
--- /dev/null
+++ b/projects/media/src/pip-button/pip-button.test.ssr.ts
@@ -0,0 +1,16 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+import { describe, expect, it } from 'vitest';
+import { ssrRunner } from '@internals/vite';
+import { MediaPipButton } from './pip-button.js';
+import './define.js';
+
+describe(MediaPipButton.metadata.tag, () => {
+ it('should pass baseline ssr check', async () => {
+ const result = await ssrRunner.render(html``);
+ expect(result.includes('shadowroot="open"')).toBe(true);
+ expect(result.includes(MediaPipButton.metadata.tag)).toBe(true);
+ });
+});
diff --git a/projects/media/src/pip-button/pip-button.test.ts b/projects/media/src/pip-button/pip-button.test.ts
new file mode 100644
index 000000000..efd397ec4
--- /dev/null
+++ b/projects/media/src/pip-button/pip-button.test.ts
@@ -0,0 +1,250 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { userEvent } from 'vitest/browser';
+import { createFixture, elementIsStable, emulateClick, removeFixture, untilEvent } from '@internals/testing';
+import { mediaCommands } from '../internal/media-command.js';
+import { createMediaState, mediaStateChange, type MediaState } from '../internal/media-state.js';
+import { MediaPipButton } from './pip-button.js';
+import './define.js';
+
+type MediaStateTarget = HTMLElement & { mediaState: MediaState };
+
+describe(MediaPipButton.metadata.tag, () => {
+ let fixture: HTMLElement;
+ let button: MediaPipButton;
+ let target: MediaStateTarget;
+
+ beforeEach(async () => {
+ fixture = await createFixture(html`
+
+
+ `);
+ button = getElement(fixture, MediaPipButton.metadata.tag);
+ target = getElement(fixture, '#target');
+ setMediaState(target, { pipAvailable: true });
+ await elementIsStable(button);
+ });
+
+ afterEach(() => {
+ removeFixture(fixture);
+ });
+
+ it('should define a command-only button with the picture-in-picture icon', () => {
+ expect(customElements.get(MediaPipButton.metadata.tag)).toBe(MediaPipButton);
+ expect(button.type).toBe('button');
+ expect(button.shadowRoot?.querySelector('nve-icon')?.getAttribute('name')).toBe('picture-in-picture');
+ expect(button.disabled).toBe(false);
+ expect(getInternals(button).role).toBe('button');
+ expect(button.tabIndex).toBe(0);
+ });
+
+ it('should sync pressed and type through inherited setters', async () => {
+ button.pressed = true;
+ expect(button.hasAttribute('pressed')).toBe(true);
+ button.removeAttribute('pressed');
+ expect(button.pressed).toBe(false);
+ button.setAttribute('pressed', '');
+ expect(button.pressed).toBe(true);
+
+ button.type = 'submit';
+ expect(button.getAttribute('type')).toBe('submit');
+ button.setAttribute('type', 'reset');
+ expect(button.type).toBe('reset');
+ button.type = 'button';
+ expect(button.getAttribute('type')).toBe('button');
+ await elementIsStable(button);
+ });
+
+ it('should send the toggle command without changing pressed state optimistically', async () => {
+ const event = untilEvent(target, 'command');
+ await emulateClick(button);
+ expect((await event).command).toBe(mediaCommands.togglePip);
+ expect(button.pressed).toBe(false);
+ });
+
+ it.each(['Enter', 'Space'])('should activate with the %s key', async key => {
+ const command = vi.fn();
+ target.addEventListener('command', command);
+ button.dispatchEvent(new KeyboardEvent('keyup', { code: key, bubbles: true }));
+ expect(command).toHaveBeenCalledOnce();
+ });
+
+ it('should mirror active PiP in pressed state and accessible name', async () => {
+ expect(getInternals(button).ariaLabel).toBe('enter picture in picture');
+ setMediaState(target, { pip: true, pipAvailable: true });
+ await elementIsStable(button);
+ expect(button.pressed).toBe(true);
+ expect(button.hasAttribute('pressed')).toBe(true);
+ expect(getInternals(button).ariaPressed).toBe('true');
+ expect(getInternals(button).ariaLabel).toBe('exit picture in picture');
+
+ setMediaState(target, { pipAvailable: true });
+ await elementIsStable(button);
+ expect(getInternals(button).ariaPressed).toBe('false');
+ expect(getInternals(button).ariaLabel).toBe('enter picture in picture');
+ });
+
+ it('should remain visible but disable focus and commands while unavailable', async () => {
+ const command = vi.fn();
+ target.addEventListener('command', command);
+ setMediaState(target, {});
+ await elementIsStable(button);
+
+ expect(button.disabled).toBe(true);
+ expect(button.hidden).toBe(false);
+ expect(getComputedStyle(button).display).toBe('inline-flex');
+ expect(button.hasAttribute('disabled')).toBe(false);
+ expect(button.matches(':state(disabled)')).toBe(true);
+ expect(getInternals(button).ariaDisabled).toBe('true');
+ expect(button.tabIndex).toBe(-1);
+ await emulateClick(button);
+ button.dispatchEvent(new KeyboardEvent('keyup', { code: 'Enter', bubbles: true }));
+ expect(command).not.toHaveBeenCalled();
+
+ setMediaState(target, { pipAvailable: true });
+ await elementIsStable(button);
+ expect(button.disabled).toBe(false);
+ expect(button.tabIndex).toBe(0);
+ });
+
+ it('should preserve consumer disabling across availability changes', async () => {
+ button.disabled = true;
+ setMediaState(target, {});
+ await elementIsStable(button);
+ setMediaState(target, { pipAvailable: true });
+ await elementIsStable(button);
+ expect(button.disabled).toBe(true);
+ expect(button.hasAttribute('disabled')).toBe(true);
+
+ button.removeAttribute('disabled');
+ await elementIsStable(button);
+ expect(button.disabled).toBe(false);
+ setMediaState(target, {});
+ button.disabled = false;
+ await elementIsStable(button);
+ expect(button.disabled).toBe(true);
+ expect(button.hasAttribute('disabled')).toBe(false);
+ });
+
+ it('should suppress enabled hover styling while PiP is unavailable', async () => {
+ fixture.style.setProperty('--nve-sys-interaction-hover-color', 'rgb(0, 128, 0)');
+ fixture.style.setProperty('--nve-sys-interaction-disabled-color', 'rgb(128, 128, 128)');
+ button.style.setProperty('--width', '32px');
+ button.style.setProperty('--height', '32px');
+ await userEvent.hover(button);
+ expect(button.matches(':hover')).toBe(true);
+ expect(getComputedStyle(button).getPropertyValue('--color').trim()).toBe('rgb(0, 128, 0)');
+
+ setMediaState(target, {});
+ await elementIsStable(button);
+ expect(button.hasAttribute('disabled')).toBe(false);
+ expect(getComputedStyle(button).getPropertyValue('--color').trim()).toBe('rgb(128, 128, 128)');
+
+ setMediaState(target, { pipAvailable: true });
+ await elementIsStable(button);
+ expect(getComputedStyle(button).getPropertyValue('--color').trim()).toBe('rgb(0, 128, 0)');
+ });
+
+ it('should allow exiting active PiP when entry becomes unavailable', async () => {
+ setMediaState(target, { pip: true });
+ await elementIsStable(button);
+ expect(button.disabled).toBe(false);
+ const event = untilEvent(target, 'command');
+ await emulateClick(button);
+ expect((await event).command).toBe(mediaCommands.togglePip);
+ });
+
+ it('should reset state when the target is missing or has no media state', async () => {
+ setMediaState(target, { pip: true });
+ await elementIsStable(button);
+ button.removeAttribute('commandfor');
+ await elementIsStable(button);
+ expect(button.disabled).toBe(true);
+ expect(button.pressed).toBe(false);
+
+ button.commandForElement = document.createElement('div');
+ await elementIsStable(button);
+ expect(button.disabled).toBe(true);
+ });
+
+ it('should retarget and ignore the previous controller', async () => {
+ const nextTarget = Object.assign(document.createElement('div'), {
+ mediaState: createMediaState({ pipAvailable: true, pip: true })
+ });
+ fixture.append(nextTarget);
+ button.commandForElement = nextTarget;
+ await elementIsStable(button);
+ expect(button.pressed).toBe(true);
+
+ setMediaState(target, {});
+ await elementIsStable(button);
+ expect(button.pressed).toBe(true);
+ expect(button.disabled).toBe(false);
+ });
+
+ it('should reset on disconnect and sync current state on reconnect', async () => {
+ setMediaState(target, { pipAvailable: true, pip: true });
+ await elementIsStable(button);
+ button.remove();
+ expect(button.pressed).toBe(false);
+ expect(button.disabled).toBe(true);
+ setMediaState(target, { pipAvailable: true });
+ fixture.append(button);
+ await elementIsStable(button);
+ expect(button.pressed).toBe(false);
+ expect(button.disabled).toBe(false);
+ });
+
+ it('should use i18n overrides and honor an explicit accessible name', async () => {
+ button.i18n = { enterPip: 'open floating video', exitPip: 'return video' };
+ await elementIsStable(button);
+ expect(getInternals(button).ariaLabel).toBe('open floating video');
+ setMediaState(target, { pip: true });
+ await elementIsStable(button);
+ expect(getInternals(button).ariaLabel).toBe('return video');
+ button.setAttribute('aria-label', 'custom video control');
+ expect(button.getAttribute('aria-label')).toBe('custom video control');
+
+ button.i18n = { enterPip: undefined, exitPip: undefined };
+ await elementIsStable(button);
+ expect(getInternals(button).ariaLabel).toBe(null);
+ });
+
+ it('should accept custom icon content', async () => {
+ const custom = document.createElement('span');
+ custom.textContent = 'custom';
+ button.append(custom);
+ await elementIsStable(button);
+ expect(button.shadowRoot?.querySelector('slot')?.assignedElements()).toEqual([custom]);
+ });
+
+ it('should not submit a form by default', async () => {
+ const form = document.createElement('form');
+ fixture.append(form);
+ form.append(button);
+ const submit = vi.fn((event: Event) => event.preventDefault());
+ form.addEventListener('submit', submit);
+ await elementIsStable(button);
+ await emulateClick(button);
+ expect(submit).not.toHaveBeenCalled();
+ expect([...new FormData(form)]).toEqual([]);
+ });
+});
+
+function getElement(root: ParentNode, selector: string) {
+ const element = root.querySelector(selector);
+ expect(element).toBeTruthy();
+ return element as T;
+}
+
+function getInternals(element: HTMLElement) {
+ return (element as HTMLElement & { _internals: ElementInternals })._internals;
+}
+
+function setMediaState(target: MediaStateTarget, state: Partial) {
+ target.mediaState = createMediaState(state);
+ target.dispatchEvent(new CustomEvent(mediaStateChange, { detail: target.mediaState }));
+}
diff --git a/projects/media/src/pip-button/pip-button.test.visual.ts b/projects/media/src/pip-button/pip-button.test.visual.ts
new file mode 100644
index 000000000..59fa077f9
--- /dev/null
+++ b/projects/media/src/pip-button/pip-button.test.visual.ts
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, test } from 'vitest';
+import { visualRunner } from '@internals/vite';
+
+describe('media pip button visual', () => {
+ test('media pip button should match visual baseline', async () => {
+ const report = await visualRunner.render('media-pip-button', template());
+ expect(report.maxDiffPercentage).toBeLessThan(1);
+ });
+
+ test('media pip button should match visual baseline dark theme', async () => {
+ const report = await visualRunner.render('media-pip-button.dark', template('dark'));
+ expect(report.maxDiffPercentage).toBeLessThan(1);
+ });
+});
+
+function template(theme: '' | 'dark' = '') {
+ return /* html */ `
+
+
+
+
+
+
+
+
+
+ `;
+}
diff --git a/projects/media/src/pip-button/pip-button.ts b/projects/media/src/pip-button/pip-button.ts
new file mode 100644
index 000000000..dbdbbd23e
--- /dev/null
+++ b/projects/media/src/pip-button/pip-button.ts
@@ -0,0 +1,108 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { property } from 'lit/decorators/property.js';
+import type { PropertyValues } from 'lit';
+import { html, LitElement } from 'lit';
+import { ButtonFormControlMixin } from '@nvidia-elements/forms/mixins';
+import { attachInternals, I18nController, scopedRegistry, useStyles } from '@nvidia-elements/core/internal';
+import { Icon } from '@nvidia-elements/core/icon';
+import { MediaStateController } from '../internal/controllers/media-state.controller.js';
+import { mediaCommands } from '../internal/media-command.js';
+import type { MediaState } from '../internal/media-state.js';
+import mediaButtonStyles from '../internal/media-button.css?inline';
+import styles from './pip-button.css?inline';
+
+/**
+ * @element nve-media-pip-button
+ * @description Requests picture-in-picture changes for the controlled video.
+ * @documentation https://nvidia.github.io/elements/docs/media/pip-button/
+ * @since 0.0.0
+ * @entrypoint \@nvidia-elements/media/pip-button
+ * @slot - Optional custom icon content.
+ * @cssprop --background
+ * @cssprop --color
+ * @cssprop --border
+ * @cssprop --border-radius
+ * @cssprop --height
+ * @cssprop --width
+ * @cssprop --padding
+ * @csspart icon - The fallback icon element.
+ * @aria https://www.w3.org/WAI/ARIA/apg/patterns/button/
+ * @stable false
+ */
+@scopedRegistry()
+export class MediaPipButton extends ButtonFormControlMixin(LitElement) {
+ static styles = useStyles([mediaButtonStyles, styles]);
+
+ static elementDefinitions = {
+ [Icon.metadata.tag]: Icon
+ };
+
+ static readonly metadata = {
+ tag: 'nve-media-pip-button',
+ version: '0.0.0'
+ };
+
+ override command: string = mediaCommands.togglePip;
+
+ @property({ type: Boolean, reflect: true, noAccessor: true }) declare pressed: boolean;
+
+ #pipAvailable = false;
+
+ override get disabled(): boolean {
+ return super.disabled || (!this.#pipAvailable && !this.pressed);
+ }
+
+ override set disabled(value: boolean) {
+ super.disabled = value;
+ }
+
+ #i18nController: I18nController = new I18nController(this);
+
+ /** Enables updating internal string values for internationalization. */
+ @property({ type: Object }) i18n = this.#i18nController.i18n;
+
+ #mediaStateController = new MediaStateController(this, mediaState => this.#syncMediaState(mediaState));
+
+ constructor() {
+ super();
+ this.type = 'button';
+ this.pressed = false;
+ }
+
+ render() {
+ return html`
+
+
+
+
+
+ `;
+ }
+
+ get #defaultAriaLabel() {
+ return (this.pressed ? this.i18n.exitPip : this.i18n.enterPip) ?? null;
+ }
+
+ override updated(changedProperties: PropertyValues) {
+ super.updated(changedProperties);
+ this.#syncDefaultAriaLabel();
+ }
+
+ #syncDefaultAriaLabel() {
+ attachInternals(this);
+ this._internals.ariaLabel = this.#defaultAriaLabel;
+ }
+
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ this.#syncMediaState(null);
+ }
+
+ #syncMediaState = (state: MediaState | null) => {
+ this.#pipAvailable = state?.pipAvailable ?? false;
+ this.pressed = state?.pip ?? false; // eslint-disable-line local/stateless-property -- PiP button mirrors the target controller state.
+ this.requestUpdate();
+ };
+}
diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js
index f2d0576d0..2e2a7f95f 100644
--- a/projects/site/src/_11ty/layouts/common.js
+++ b/projects/site/src/_11ty/layouts/common.js
@@ -324,6 +324,7 @@ export const renderDocsNav = data => /* html */ `
Playback Rate Select
Loop Button
Mute Button
+ Media PiP Button
Seek Button
Time Range
Volume Range
diff --git a/projects/site/src/docs/media/controller.md b/projects/site/src/docs/media/controller.md
index 7e1be74af..fafb6c713 100644
--- a/projects/site/src/docs/media/controller.md
+++ b/projects/site/src/docs/media/controller.md
@@ -31,7 +31,7 @@
## State
-Read the latest immutable state from `mediaState`. The state includes `paused`, `muted`, `ended`, `seeking`, `fullscreen`, `loop`, `currentTime`, `duration`, `volume`, `playbackRate`, and `buffered`.
+Read the latest immutable state from `mediaState`. The state includes `paused`, `muted`, `ended`, `seeking`, `fullscreen`, `loop`, `pip`, `pipAvailable`, `currentTime`, `duration`, `volume`, `playbackRate`, and `buffered`.
The `buffered` value contains ordered, disjoint time spans in seconds. The controller copies these spans from the native media element instead of exposing the browser-owned `TimeRanges` object.
@@ -49,6 +49,6 @@ console.log(controller.mediaState);
controller.addEventListener('media-state-change', event => console.log(event.detail));
```
-The controller continues to reflect `paused`, `muted`, `ended`, `seeking`, `fullscreen`, `loop`, `current-time`, `duration`, `volume`, and `playback-rate` for styling and inspection. Buffered spans remain available through `mediaState` and `media-state-change`. The native media element still emits events such as `play`, `pause`, `progress`, `timeupdate`, `durationchange`, `volumechange`, and `ratechange`.
+The controller continues to reflect `paused`, `muted`, `ended`, `seeking`, `fullscreen`, `loop`, `pip`, `pip-available`, `current-time`, `duration`, `volume`, and `playback-rate` for styling and inspection. Buffered spans remain available through `mediaState` and `media-state-change`. The native media element still emits events such as `play`, `pause`, `progress`, `timeupdate`, `durationchange`, `volumechange`, and `ratechange`.
The controller does not include `src`, `poster`, `autoplay`, track, or caption APIs. Use native media markup for media ownership and compose the Elements controls around it.
diff --git a/projects/site/src/docs/media/pip-button.md b/projects/site/src/docs/media/pip-button.md
new file mode 100644
index 000000000..c087e5310
--- /dev/null
+++ b/projects/site/src/docs/media/pip-button.md
@@ -0,0 +1,24 @@
+---
+{
+ title: 'Media Picture-in-Picture Button',
+ layout: 'docs.11ty.js',
+ tag: 'nve-media-pip-button',
+ hideExamplesTab: true
+}
+---
+
+## Installation
+
+{% install 'nve-media-pip-button' %}
+
+## Usage
+
+`nve-media-pip-button` sends `--toggle-pip` to the target controller. The controller requests picture-in-picture mode on its slotted video. Click again to return the video to the page. The controller supports `--enter-pip`, `--exit-pip`, and `--toggle-pip`. These commands operate on the controller's video. An exit command does not close another video's picture-in-picture window.
+
+{% example '@nvidia-elements/media/pip-button/pip-button.examples.json' 'Default' %}
+
+## Browser Support
+
+This control uses the standard [Picture-in-Picture API](https://developer.mozilla.org/en-US/docs/Web/API/Picture-in-Picture_API). It requires browser support, loaded video metadata, and user activation for entry. Browser compatibility and permissions policy can limit availability.
+
+This component does not use Safari-specific presentation APIs, the Document Picture-in-Picture API, or Media Session action handlers. Browser actions that do not emit standard picture-in-picture events might not update the mirrored state immediately.