diff --git a/src/js/constants.js b/src/js/constants.js index 90610ac..7b8b1b9 100644 --- a/src/js/constants.js +++ b/src/js/constants.js @@ -124,3 +124,8 @@ export const SETTING_KEY_DARK_MODE = 'dark-mode'; * Event name signalling that data in IDB has changes. */ export const IDB_DATA_CHANGED_EVENT = 'idb-data-changed'; + +/** + * Picture in picture. + */ +export const PIP_CLASSNAME = 'picture-in-picture'; diff --git a/src/js/web-components/video-player/VideoPlayer.css b/src/js/web-components/video-player/VideoPlayer.css index adf8a92..9251f22 100644 --- a/src/js/web-components/video-player/VideoPlayer.css +++ b/src/js/web-components/video-player/VideoPlayer.css @@ -1,6 +1,7 @@ :host { display: flex; align-items: center; + position: relative; } :host *:focus { @@ -9,7 +10,63 @@ outline-color: var(--accent); } +:host .floating-buttons { + display: grid; + grid-auto-flow: column; + column-gap: 16px; + position: absolute; + top: 16px; + right: 16px; + z-index: 2; +} + +@media (min-width: 720px) { + :host .floating-buttons { + top: 32px; + right: 32px; + } +} + +:host .floating-buttons button { + border-radius: 8px; + background-color: var(--accent-background); + width: 48px; + height: 48px; + display: grid; + place-items: center; + cursor: pointer; + border: none; +} + video { width: 100%; height: auto; } + +.pip-overlay { + display: none; + position: absolute; + inset: 0; + background-color: var(--code-background); + color: var(--icon); + z-index: 1; + font-size: clamp(12px, 4vw, 24px); +} + +.pip-overlay svg { + align-self: end; + width: clamp(40px, 20vw, 128px); + height: auto; +} + +:host(.picture-in-picture) .pip-overlay { + display: grid; + justify-items: center; + row-gap: 16px; +} + +@media (min-width: 720px) { + :host(.picture-in-picture) .pip-overlay { + row-gap: 32px; + } +} diff --git a/src/js/web-components/video-player/VideoPlayer.js b/src/js/web-components/video-player/VideoPlayer.js index 48b12a9..ef124cb 100644 --- a/src/js/web-components/video-player/VideoPlayer.js +++ b/src/js/web-components/video-player/VideoPlayer.js @@ -19,7 +19,10 @@ import Streamer from '../../classes/Streamer'; import ParserMPD from '../../classes/ParserMPD'; import selectSource from '../../utils/selectSource'; -import { MEDIA_SESSION_DEFAULT_ARTWORK } from '../../constants'; +import { + MEDIA_SESSION_DEFAULT_ARTWORK, + PIP_CLASSNAME, +} from '../../constants'; export default class extends HTMLElement { /** @@ -86,6 +89,11 @@ export default class extends HTMLElement { ${this.getSourceHTML()} ${this.getTracksHTML()} +
+ `; while (this.internal.root.firstChild) { @@ -96,6 +104,13 @@ export default class extends HTMLElement { this.videoElement = this.internal.root.querySelector('video'); this.videoElement.addEventListener('error', this.handleVideoError.bind(this), true); + const pipButton = this.createPiPButton(); + const floatingButtonsBar = this.internal.root.querySelector('.floating-buttons'); + + if (pipButton) { + floatingButtonsBar.appendChild(pipButton); + } + /** * Set up Media Session API integration. */ @@ -299,4 +314,58 @@ export default class extends HTMLElement { assetsOnly: true, }); } + + /** + * Returns a button that controls the PiP functionality. + * + * @returns {HTMLButtonElement|null} Button element or null when PiP not supported. + */ + createPiPButton() { + if (!('pictureInPictureEnabled' in document)) { + return null; + } + const ENTER_PIP_SVG = ''; + const LEAVE_PIP_SVG = ''; + + const pipButton = document.createElement('button'); + const setPipButton = () => { + pipButton.disabled = (this.videoElement.readyState === 0) + || !document.pictureInPictureEnabled + || this.videoElement.disablePictureInPicture; + }; + + pipButton.setAttribute('aria-label', 'Toggle picture in picture'); + pipButton.innerHTML = ENTER_PIP_SVG; + + pipButton.addEventListener('click', async () => { + pipButton.disabled = true; + try { + if (this !== document.pictureInPictureElement) { + await this.videoElement.requestPictureInPicture(); + } else { + await document.exitPictureInPicture(); + } + } catch (error) { + /* eslint-disable-next-line no-console */ + console.error(error); + } finally { + pipButton.disabled = false; + } + }); + + this.videoElement.addEventListener('loadedmetadata', setPipButton); + this.videoElement.addEventListener('emptied', setPipButton); + this.videoElement.addEventListener('enterpictureinpicture', () => { + pipButton.innerHTML = LEAVE_PIP_SVG; + this.classList.add(PIP_CLASSNAME); + }); + this.videoElement.addEventListener('leavepictureinpicture', () => { + pipButton.innerHTML = ENTER_PIP_SVG; + this.classList.remove(PIP_CLASSNAME); + }); + + setPipButton(); + + return pipButton; + } }