1
0
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:
mister-ben 2023-04-04 22:44:16 +02:00 committed by GitHub
parent 882f3af3b2
commit 0c72805500
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 320 additions and 13 deletions

View File

@ -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>

View File

@ -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"
}

View File

@ -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"
}

View 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>

View File

@ -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;
}

View File

@ -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; }

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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();

View File

@ -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
*

View File

@ -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);

View File

@ -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 = [];