diff --git a/index.html b/index.html index 911151746a..bdb80976ff 100644 --- a/index.html +++ b/index.html @@ -27,6 +27,7 @@

Navigation

  • QualityLevels Demo
  • Autoplay Tests
  • noUITitleAttributes Demo
  • +
  • Document Picture-In-Picture Demo
  • Skip Buttons demo
  • Videojs debug build test page
  • diff --git a/lang/de.json b/lang/de.json index 07c155a9e6..3bac9e8de2 100644 --- a/lang/de.json +++ b/lang/de.json @@ -91,6 +91,7 @@ "Opacity": "Deckkraft", "Text Background": "Texthintergrund", "Caption Area Background": "Hintergrund des Untertitelbereichs", + "Playing in Picture-in-Picture": "Wird im Bild-im-Bild-Modus wiedergegeben", "Skip forward {1} seconds": "{1} Sekunden vorwärts", "Skip backward {1} seconds": "{1} Sekunden zurück" } diff --git a/lang/en.json b/lang/en.json index 6d42e9bc87..2ab55b7c0c 100644 --- a/lang/en.json +++ b/lang/en.json @@ -91,6 +91,7 @@ "Opacity": "Opacity", "Text Background": "Text Background", "Caption Area Background": "Caption Area Background", + "Playing in Picture-in-Picture": "Playing in Picture-in-Picture", "Skip backward {1} seconds": "Skip backward {1} seconds", "Skip forward {1} seconds": "Skip forward {1} seconds" } diff --git a/sandbox/docpip.html.example b/sandbox/docpip.html.example new file mode 100644 index 0000000000..724aa58868 --- /dev/null +++ b/sandbox/docpip.html.example @@ -0,0 +1,52 @@ + + + + + Video.js Sandbox + + + + + +
    +

    You can use /sandbox/ for writing and testing your own code. Nothing in /sandbox/ will get checked into the repo, except files that end in .example (so don't edit or add those files). To get started run `npm start` and open the index.html

    +
    npm start
    +
    open http://localhost:9999/sandbox/index.html
    +
    + +

    Document Picture-in-Picture is available in Chrome version 111 onwards.

    + + + + + + + + diff --git a/src/css/components/_fullscreen.scss b/src/css/components/_fullscreen.scss index a9deba41f1..f682341818 100644 --- a/src/css/components/_fullscreen.scss +++ b/src/css/components/_fullscreen.scss @@ -7,7 +7,8 @@ } } -.video-js.vjs-audio-only-mode .vjs-fullscreen-control { +.video-js.vjs-audio-only-mode .vjs-fullscreen-control, +.vjs-pip-window .vjs-fullscreen-control { display: none; } diff --git a/src/css/components/_layout.scss b/src/css/components/_layout.scss index 0a663fde77..a8238160a1 100644 --- a/src/css/components/_layout.scss +++ b/src/css/components/_layout.scss @@ -119,13 +119,15 @@ display: none; } -// Fullscreen Styles -body.vjs-full-window { +// Fullscreen and Document Picture-in-Picture Styles +body.vjs-full-window, +body.vjs-pip-window { padding: 0; margin: 0; height: 100%; } -.vjs-full-window .video-js.vjs-fullscreen { +.vjs-full-window .video-js.vjs-fullscreen, +body.vjs-pip-window .video-js { position: fixed; overflow: hidden; z-index: 1000; @@ -134,7 +136,8 @@ body.vjs-full-window { bottom: 0; right: 0; } -.video-js.vjs-fullscreen:not(.vjs-ios-native-fs) { +.video-js.vjs-fullscreen:not(.vjs-ios-native-fs), +body.vjs-pip-window .video-js { width: 100% !important; height: 100% !important; // Undo any aspect ratio padding for fluid layouts @@ -145,6 +148,23 @@ body.vjs-full-window { cursor: none; } +.vjs-pip-container .vjs-pip-text { + position: absolute; + bottom: 10%; + font-size: 2em; + background-color: rgba(0, 0, 0, .7); + padding: .5em; + text-align: center; + width: 100% +} + +.vjs-layout-tiny.vjs-pip-container .vjs-pip-text, +.vjs-layout-x-small.vjs-pip-container .vjs-pip-text, +.vjs-layout-small.vjs-pip-container .vjs-pip-text { + bottom: 0; + font-size: 1.4em; +} + // Hide disabled or unsupported controls. .vjs-hidden { display: none !important; } diff --git a/src/css/components/_picture-in-picture.scss b/src/css/components/_picture-in-picture.scss index cfba09b928..5992407361 100644 --- a/src/css/components/_picture-in-picture.scss +++ b/src/css/components/_picture-in-picture.scss @@ -7,7 +7,8 @@ } } -.video-js.vjs-audio-only-mode .vjs-picture-in-picture-control { +.video-js.vjs-audio-only-mode .vjs-picture-in-picture-control, +.vjs-pip-window .vjs-picture-in-picture-control { display: none; } diff --git a/src/css/components/_poster.scss b/src/css/components/_poster.scss index ba77026332..d1f1d224a9 100644 --- a/src/css/components/_poster.scss +++ b/src/css/components/_poster.scss @@ -20,7 +20,8 @@ // Don't hide the poster if we're playing audio or when audio-poster-mode is true .vjs-audio.vjs-has-started .vjs-poster, -.vjs-has-started.vjs-audio-poster-mode .vjs-poster { +.vjs-has-started.vjs-audio-poster-mode .vjs-poster, +.vjs-pip-container.vjs-has-started .vjs-poster { display: block; } diff --git a/src/css/components/menu/_menu-popup.scss b/src/css/components/menu/_menu-popup.scss index dbc0435a48..550c7615b2 100644 --- a/src/css/components/menu/_menu-popup.scss +++ b/src/css/components/menu/_menu-popup.scss @@ -9,6 +9,11 @@ border-top-color: rgba($primary-background-color, $primary-background-transparency); // Same as ul background } +.vjs-pip-window .vjs-menu-button-popup .vjs-menu { + left: unset; + right: 1em; // Extra offset for last menu button in pip window, as fullscreen button not present +} + // Button Pop-up Menu .vjs-menu-button-popup .vjs-menu .vjs-menu-content { @include background-color-with-alpha($primary-background-color, $primary-background-transparency); diff --git a/src/js/control-bar/picture-in-picture-toggle.js b/src/js/control-bar/picture-in-picture-toggle.js index 7fb9745b3d..edc92faa09 100644 --- a/src/js/control-bar/picture-in-picture-toggle.js +++ b/src/js/control-bar/picture-in-picture-toggle.js @@ -4,6 +4,7 @@ import Button from '../button.js'; import Component from '../component.js'; import document from 'global/document'; +import window from 'global/window'; /** * @typedef { import('./player').default } Player @@ -63,11 +64,19 @@ class PictureInPictureToggle extends Button { } /** - * Enables or disables button based on document.pictureInPictureEnabled property value - * or on value returned by player.disablePictureInPicture() method. + * Enables or disables button based on availability of a Picture-In-Picture mode. + * + * Enabled if + * - `player.options().enableDocumentPictureInPicture` is true and + * window.documentPictureInPicture is available; or + * - `player.disablePictureInPicture()` is false and + * element.requestPictureInPicture is available */ handlePictureInPictureEnabledChange() { - if (document.pictureInPictureEnabled && this.player_.disablePictureInPicture() === false) { + if ( + (document.pictureInPictureEnabled && this.player_.disablePictureInPicture() === false) || + (this.player_.options_.enableDocumentPictureInPicture && 'documentPictureInPicture' in window) + ) { this.enable(); } else { this.disable(); diff --git a/src/js/player.js b/src/js/player.js index 6c58fb06ab..89e164449b 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -3037,7 +3037,14 @@ class Player extends Component { * continue consuming media while they interact with other content sites, or * applications on their device. * - * @see [Spec]{@link https://wicg.github.io/picture-in-picture} + * This can use document picture-in-picture or element picture in picture + * + * Set `enableDocumentPictureInPicture` to `true` to use docPiP on a supported browser + * Else set `disablePictureInPicture` to `false` to disable elPiP on a supported browser + * + * + * @see [Spec]{@link https://w3c.github.io/picture-in-picture/} + * @see [Spec]{@link https://wicg.github.io/document-picture-in-picture/} * * @fires Player#enterpictureinpicture * @@ -3045,6 +3052,44 @@ class Player extends Component { * A promise with a Picture-in-Picture window. */ requestPictureInPicture() { + if (this.options_.enableDocumentPictureInPicture && window.documentPictureInPicture) { + const pipContainer = document.createElement(this.el().tagName); + + pipContainer.classList = this.el().classList; + pipContainer.classList.add('vjs-pip-container'); + if (this.posterImage) { + pipContainer.appendChild(this.posterImage.el().cloneNode(true)); + } + if (this.titleBar) { + pipContainer.appendChild(this.titleBar.el().cloneNode(true)); + } + pipContainer.appendChild(Dom.createEl('p', { className: 'vjs-pip-text' }, {}, this.localize('Playing in picture-in-picture'))); + + return window.documentPictureInPicture.requestWindow({ + // The aspect ratio won't be correct, Chrome bug https://crbug.com/1407629 + initialAspectRatio: this.videoWidth() / this.videoHeight(), + copyStyleSheets: true + }).then(pipWindow => { + this.el_.parentNode.insertBefore(pipContainer, this.el_); + + pipWindow.document.body.append(this.el_); + pipWindow.document.body.classList.add('vjs-pip-window'); + + this.player_.isInPictureInPicture(true); + this.player_.trigger('enterpictureinpicture'); + + // Listen for the PiP closing event to move the video back. + pipWindow.addEventListener('unload', (event) => { + const pipVideo = event.target.querySelector('.video-js'); + + pipContainer.replaceWith(pipVideo); + this.player_.isInPictureInPicture(false); + this.player_.trigger('leavepictureinpicture'); + }); + + return pipWindow; + }); + } if ('pictureInPictureEnabled' in document && this.disablePictureInPicture() === false) { /** * This event fires when the player enters picture in picture mode @@ -3054,6 +3099,7 @@ class Player extends Component { */ return this.techGet_('requestPictureInPicture'); } + return Promise.reject('No PiP mode is available'); } /** @@ -3067,7 +3113,13 @@ class Player extends Component { * A promise. */ exitPictureInPicture() { + if (window.documentPictureInPicture && window.documentPictureInPicture.window) { + // With documentPictureInPicture, Player#leavepictureinpicture is fired in the unload handler + window.documentPictureInPicture.window.close(); + return Promise.resolve(); + } if ('pictureInPictureEnabled' in document) { + /** * This event fires when the player leaves picture in picture mode * diff --git a/test/unit/controls.test.js b/test/unit/controls.test.js index 6e225f9472..face2c358b 100644 --- a/test/unit/controls.test.js +++ b/test/unit/controls.test.js @@ -13,6 +13,7 @@ import SeekBar from '../../src/js/control-bar/progress-control/seek-bar.js'; import RemainingTimeDisplay from '../../src/js/control-bar/time-controls/remaining-time-display.js'; import TestHelpers from './test-helpers.js'; import document from 'global/document'; +import window from 'global/window'; import sinon from 'sinon'; QUnit.module('Controls', { @@ -300,6 +301,30 @@ QUnit.test('Picture-in-Picture control is hidden when the source is audio', func pictureInPictureToggle.dispose(); }); +QUnit.test('Picture-in-Picture control is displayed if docPiP is enabled', function(assert) { + const player = TestHelpers.makePlayer({ + disablePictureInPicture: true, + enableDocumentPictureInPicture: true + }); + const pictureInPictureToggle = new PictureInPictureToggle(player); + const testPiPObj = {}; + + if (!window.documentPictureInPicture) { + window.documentPictureInPicture = testPiPObj; + } + + player.src({src: 'example.mp4', type: 'video/mp4'}); + player.trigger('loadedmetadata'); + + assert.notOk(pictureInPictureToggle.hasClass('vjs-hidden'), 'pictureInPictureToggle button is not hidden'); + + player.dispose(); + pictureInPictureToggle.dispose(); + if (window.documentPictureInPicture === testPiPObj) { + delete window.documentPictureInPicture; + } +}); + QUnit.test('Fullscreen control text should be correct when fullscreenchange is triggered', function(assert) { const player = TestHelpers.makePlayer({controlBar: false}); const fullscreentoggle = new FullscreenToggle(player); diff --git a/test/unit/player.test.js b/test/unit/player.test.js index 300ae846bb..09e69e773d 100644 --- a/test/unit/player.test.js +++ b/test/unit/player.test.js @@ -2750,14 +2750,152 @@ QUnit[testOrSkip]('Should only allow requestPictureInPicture if the tech support assert.equal(count, 1, 'requestPictureInPicture passed through to supporting tech'); player.tech_.el_.disablePictureInPicture = true; - player.requestPictureInPicture(); + player.requestPictureInPicture().catch(_ => {}); assert.equal(count, 1, 'requestPictureInPicture not passed through when disabled on tech'); delete player.tech_.el_.disablePictureInPicture; - player.requestPictureInPicture(); + player.requestPictureInPicture().catch(_ => {}); assert.equal(count, 1, 'requestPictureInPicture not passed through when tech does not support'); }); +QUnit.test('document pictureinpicture is opt-in', function(assert) { + const done = assert.async(); + const player = TestHelpers.makePlayer({ + disablePictureInPicture: true + }); + + const testPiPObj = {}; + + if (!window.documentPictureInPicture) { + window.documentPictureInPicture = testPiPObj; + } + + player.requestPictureInPicture().catch(e => { + assert.equal(e, 'No PiP mode is available', 'docPiP not used when not enabled'); + }).finally(_ => { + if (window.documentPictureInPicture === testPiPObj) { + delete window.documentPictureInPicture; + } + done(); + }); + +}); + +QUnit.test('docPiP is used in preference to winPiP', function(assert) { + assert.expect(2); + + const done = assert.async(); + const player = TestHelpers.makePlayer({ + enableDocumentPictureInPicture: true + }); + let count = 0; + + player.tech_.el_ = { + disablePictureInPicture: false, + requestPictureInPicture() { + count++; + } + }; + + const testPiPObj = { + requestWindow() { + return Promise.resolve({}); + } + }; + + if (!window.documentPictureInPicture) { + window.documentPictureInPicture = testPiPObj; + } + + // Test isn't concerned with whether the browser allows the request, + player.requestPictureInPicture().then(_ => { + assert.ok(true, 'docPiP was called'); + }).catch(_ => { + assert.ok(true, 'docPiP was called'); + }).finally(_ => { + assert.equal(0, count, 'requestPictureInPicture not passed to tech'); + if (window.documentPictureInPicture === testPiPObj) { + delete window.documentPictureInPicture; + } + done(); + }); +}); + +QUnit.test('docPiP moves player and triggers events', function(assert) { + const done = assert.async(); + const player = TestHelpers.makePlayer({ + enableDocumentPictureInPicture: true + }); + const playerParent = player.el().parentElement; + + player.videoHeight = () => 9; + player.videoWidth = () => 16; + + const counts = { + enterpictureinpicture: 0, + leavepictureinpicture: 0 + }; + + player.on(Object.keys(counts), function(e) { + counts[e.type]++; + }); + + const fakePiPWindow = document.createElement('div'); + + fakePiPWindow.document = { + body: document.createElement('div') + }; + fakePiPWindow.querySelector = function(sel) { + return fakePiPWindow.document.body.querySelector(sel); + }; + fakePiPWindow.close = function() { + fakePiPWindow.dispatchEvent(new Event('unload')); + delete window.documentPictureInPicture.window; + }; + + const testPiPObj = { + requestWindow() { + window.documentPictureInPicture.window = fakePiPWindow; + return Promise.resolve(fakePiPWindow); + } + }; + + if (!window.documentPictureInPicture) { + window.documentPictureInPicture = testPiPObj; + } + + player.requestPictureInPicture().then(win => { + assert.ok(player.el().parentElement === win.document.body, 'player el was moved'); + assert.ok(playerParent.querySelector('.vjs-pip-container'), 'placeholder el was added'); + assert.ok(player.isInPictureInPicture(), 'player is in pip state'); + assert.equal(counts.enterpictureinpicture, 1, '`enterpictureinpicture` triggered'); + + player.exitPictureInPicture().then(_ => { + assert.ok(player.el().parentElement === playerParent, 'player el was restored'); + assert.notOk(playerParent.querySelector('.vjs-pip-container'), 'placeholder el was removed'); + assert.notOk(player.isInPictureInPicture(), 'player is not in pip state'); + assert.equal(counts.leavepictureinpicture, 1, '`leavepictureinpicture` triggered'); + + if (window.documentPictureInPicture === testPiPObj) { + delete window.documentPictureInPicture; + } + done(); + }); + }).catch(e => { + if (e === 'No PiP mode is available') { + assert.ok(true, 'Test skipped because PiP not available'); + } else if (e.name && e.name === 'NotAllowedError') { + assert.ok(true, 'Test skipped because PiP not allowed'); + } else { + assert.notOk(true, 'An unexpected error occurred'); + } + if (window.documentPictureInPicture === testPiPObj) { + delete window.documentPictureInPicture; + } + done(); + }); +}); + QUnit.test('playbackRates should trigger a playbackrateschange event', function(assert) { const player = TestHelpers.makePlayer({}); const rates = [];