mirror of
https://github.com/videojs/video.js.git
synced 2024-12-25 02:42:10 +02:00
feat: Add document picture-in-picture support (#8113)
Co-authored-by: François Beaufort <beaufort.francois@gmail.com>
This commit is contained in:
parent
882f3af3b2
commit
0c72805500
@ -27,6 +27,7 @@
|
||||
<li><a href="sandbox/quality-levels.html">QualityLevels Demo</a></li>
|
||||
<li><a href="sandbox/autoplay-tests.html">Autoplay Tests</a></li>
|
||||
<li><a href="sandbox/noUITitleAttributes.html">noUITitleAttributes Demo</a></li>
|
||||
<li><a href="sandbox/docpip.html">Document Picture-In-Picture Demo</a></li>
|
||||
<li><a href="sandbox/skip-buttons.html">Skip Buttons demo</a></li>
|
||||
<li><a href="sandbox/debug.html">Videojs debug build test page</a></li>
|
||||
</ul>
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
|
52
sandbox/docpip.html.example
Normal file
52
sandbox/docpip.html.example
Normal file
@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Video.js Sandbox</title>
|
||||
<link href="../dist/video-js.css" rel="stylesheet" type="text/css">
|
||||
<script src="../dist/video.js"></script>
|
||||
<meta http-equiv="origin-trial" content="AruMDfzKHqbkAi4xRXZRAmpUv/hnpKsuR0VB+B6S7TGJOZBQv6ZQ0jaH6+EDW1tHjwYBlBAObmYinZ/aGtaLGwQAAACYeyJvcmlnaW4iOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTgxMTMtLXZpZGVvanMtcHJldmlldy5uZXRsaWZ5LmFwcDo0NDMiLCJmZWF0dXJlIjoiRG9jdW1lbnRQaWN0dXJlSW5QaWN0dXJlQVBJIiwiZXhwaXJ5IjoxNjk0MTMxMTk5LCJpc1N1YmRvbWFpbiI6dHJ1ZX0=" />
|
||||
</head>
|
||||
<body>
|
||||
<div style="background-color:#eee; border: 1px solid #777; padding: 10px; margin-bottom: 20px; font-size: .8em; line-height: 1.5em; font-family: Verdana, sans-serif;">
|
||||
<p>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</p>
|
||||
<pre>npm start</pre>
|
||||
<pre>open http://localhost:9999/sandbox/index.html</pre>
|
||||
</div>
|
||||
|
||||
<p>Document Picture-in-Picture is available in Chrome version 111 onwards.</p>
|
||||
|
||||
<video-js
|
||||
id="vid1"
|
||||
controls
|
||||
preload="auto"
|
||||
width="640"
|
||||
height="264"></video-js>
|
||||
</video-js>
|
||||
|
||||
<script>
|
||||
var vid = document.getElementById('vid1');
|
||||
var player = videojs(vid, {
|
||||
enableDocumentPictureInPicture: true
|
||||
});
|
||||
player.loadMedia({
|
||||
artist: 'Disney',
|
||||
album: 'Oceans',
|
||||
title: 'Oceans',
|
||||
description: 'Journey in to the depths of a wonderland filled with mystery, beauty and power. Oceans is a spectacular story, narrated by Pierce Brosnan, about remarkable creatures under the sea. It\'s an unprecedented look at the lives of these elusive deepwater creatures through their own eyes. Incredible state-of-the-art-underwater filmmaking will take your breath away as you migrate with whales, swim alongside a great white shark and race with dolphins at play.',
|
||||
poster: 'https://vjs.zencdn.net/v/oceans.png',
|
||||
src: [{
|
||||
src: 'https://vjs.zencdn.net/v/oceans.mp4',
|
||||
type: 'video/mp4',
|
||||
}]
|
||||
})
|
||||
|
||||
player.on(['enterpictureinpicture', 'leavepictureinpicture', 'disablepictureinpicturechanged'], e => {
|
||||
console.log(e.type);
|
||||
});
|
||||
player.disablePictureInPicture(true);
|
||||
player.log('window.player created', player);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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; }
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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);
|
||||
|
@ -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 = [];
|
||||
|
Loading…
Reference in New Issue
Block a user