From 407f124cb91f6980f418985b632a4ef349c911fd Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Tue, 6 Oct 2026 10:16:01 -0500 Subject: [PATCH 1/4] fix(core): add pip i18n strings Signed-off-by: Cory Rylan --- .../core/src/internal/controllers/i18n.controller.test.ts | 4 ++++ projects/core/src/internal/services/i18n.service.test.ts | 2 ++ projects/core/src/internal/services/i18n.service.ts | 4 ++++ 3 files changed, 10 insertions(+) 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', From 8c3c9843df8db0b71d6d7bff366546cea12e94f9 Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Tue, 6 Oct 2026 10:20:38 -0500 Subject: [PATCH 2/4] feat(media): picture in picture button Signed-off-by: Cory Rylan --- .../media/.visual/media-pip-button.dark.png | 3 + projects/media/.visual/media-pip-button.png | 3 + projects/media/package.json | 12 + .../src/controller/controller.examples.ts | 1 + .../src/controller/controller.pip.test.ts | 319 ++++++++++++++++++ .../controller/controller.test.lighthouse.ts | 2 +- projects/media/src/controller/controller.ts | 113 ++++++- .../button-form-control-usage.test.ts | 12 +- projects/media/src/internal/media-button.css | 2 +- projects/media/src/internal/media-command.ts | 5 +- .../media/src/internal/media-state.test.ts | 14 + projects/media/src/internal/media-state.ts | 6 +- projects/media/src/pip-button/define.ts | 13 + projects/media/src/pip-button/index.ts | 4 + projects/media/src/pip-button/pip-button.css | 2 + .../src/pip-button/pip-button.examples.ts | 21 ++ .../src/pip-button/pip-button.test.axe.ts | 34 ++ .../pip-button/pip-button.test.lighthouse.ts | 21 ++ .../src/pip-button/pip-button.test.ssr.ts | 16 + .../media/src/pip-button/pip-button.test.ts | 233 +++++++++++++ .../src/pip-button/pip-button.test.visual.ts | 38 +++ projects/media/src/pip-button/pip-button.ts | 104 ++++++ projects/site/src/_11ty/layouts/common.js | 1 + projects/site/src/docs/media/controller.md | 4 +- projects/site/src/docs/media/pip-button.md | 24 ++ 25 files changed, 989 insertions(+), 18 deletions(-) create mode 100644 projects/media/.visual/media-pip-button.dark.png create mode 100644 projects/media/.visual/media-pip-button.png create mode 100644 projects/media/src/controller/controller.pip.test.ts create mode 100644 projects/media/src/pip-button/define.ts create mode 100644 projects/media/src/pip-button/index.ts create mode 100644 projects/media/src/pip-button/pip-button.css create mode 100644 projects/media/src/pip-button/pip-button.examples.ts create mode 100644 projects/media/src/pip-button/pip-button.test.axe.ts create mode 100644 projects/media/src/pip-button/pip-button.test.lighthouse.ts create mode 100644 projects/media/src/pip-button/pip-button.test.ssr.ts create mode 100644 projects/media/src/pip-button/pip-button.test.ts create mode 100644 projects/media/src/pip-button/pip-button.test.visual.ts create mode 100644 projects/media/src/pip-button/pip-button.ts create mode 100644 projects/site/src/docs/media/pip-button.md 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', () => {
${submitTemplate}
-
+
`); 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..3924d4725 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', '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..0d7946c20 --- /dev/null +++ b/projects/media/src/pip-button/pip-button.test.ts @@ -0,0 +1,233 @@ +// 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 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..11b65422e --- /dev/null +++ b/projects/media/src/pip-button/pip-button.ts @@ -0,0 +1,104 @@ +// 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, type ButtonType } 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; + + override type: ButtonType = 'button'; + + @property({ type: Boolean, reflect: true }) pressed = false; + + #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)); + + 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. From 584f1e379f34a67a239b1e271313acd022c951e2 Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Tue, 6 Oct 2026 10:21:39 -0500 Subject: [PATCH 3/4] fix(lint): add invoker trigger element reference Signed-off-by: Cory Rylan --- .../src/eslint/rules/no-invalid-invoker-triggers.test.ts | 9 ++++++--- .../lint/src/eslint/rules/no-invalid-invoker-triggers.ts | 1 + projects/media/src/internal/media-state.ts | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) 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/src/internal/media-state.ts b/projects/media/src/internal/media-state.ts index 3924d4725..1f4f6e09a 100644 --- a/projects/media/src/internal/media-state.ts +++ b/projects/media/src/internal/media-state.ts @@ -28,7 +28,7 @@ export type MediaStateChangeEvent = CustomEvent; export const mediaStateChange = 'media-state-change'; -const booleanStateKeys = ['ended', 'fullscreen', 'muted', 'paused', 'pip', 'pipAvailable', 'seeking'] as const; +const booleanStateKeys = ['ended', 'fullscreen', 'loop', 'muted', 'paused', 'pip', 'pipAvailable', 'seeking'] as const; const numberStateKeys = ['currentTime', 'duration', 'playbackRate', 'volume'] as const; From b998123a83f232c78c087fc569a4abeba127aa13 Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Tue, 6 Oct 2026 11:10:04 -0500 Subject: [PATCH 4/4] chore(core): update lighthouse Signed-off-by: Cory Rylan --- .../star-rating/star-rating.test.lighthouse.ts | 2 +- projects/core/src/tree/tree.test.lighthouse.ts | 3 +-- .../media/src/pip-button/pip-button.test.ts | 17 +++++++++++++++++ projects/media/src/pip-button/pip-button.ts | 12 ++++++++---- 4 files changed, 27 insertions(+), 7 deletions(-) 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/media/src/pip-button/pip-button.test.ts b/projects/media/src/pip-button/pip-button.test.ts index 0d7946c20..efd397ec4 100644 --- a/projects/media/src/pip-button/pip-button.test.ts +++ b/projects/media/src/pip-button/pip-button.test.ts @@ -41,6 +41,23 @@ describe(MediaPipButton.metadata.tag, () => { 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); diff --git a/projects/media/src/pip-button/pip-button.ts b/projects/media/src/pip-button/pip-button.ts index 11b65422e..dbdbbd23e 100644 --- a/projects/media/src/pip-button/pip-button.ts +++ b/projects/media/src/pip-button/pip-button.ts @@ -4,7 +4,7 @@ import { property } from 'lit/decorators/property.js'; import type { PropertyValues } from 'lit'; import { html, LitElement } from 'lit'; -import { ButtonFormControlMixin, type ButtonType } from '@nvidia-elements/forms/mixins'; +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'; @@ -46,9 +46,7 @@ export class MediaPipButton extends ButtonFormControlMixin(LitElement) { override command: string = mediaCommands.togglePip; - override type: ButtonType = 'button'; - - @property({ type: Boolean, reflect: true }) pressed = false; + @property({ type: Boolean, reflect: true, noAccessor: true }) declare pressed: boolean; #pipAvailable = false; @@ -67,6 +65,12 @@ export class MediaPipButton extends ButtonFormControlMixin(LitElement) { #mediaStateController = new MediaStateController(this, mediaState => this.#syncMediaState(mediaState)); + constructor() { + super(); + this.type = 'button'; + this.pressed = false; + } + render() { return html`