Source code
Revision control
Copy as Markdown
Other Tools
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
const { PictureInPicture } = ChromeUtils.importESModule(
"resource://gre/modules/PictureInPicture.sys.mjs"
);
const { ShortcutUtils } = ChromeUtils.importESModule(
"resource://gre/modules/ShortcutUtils.sys.mjs"
);
const { DeferredTask } = ChromeUtils.importESModule(
"resource://gre/modules/DeferredTask.sys.mjs"
);
const { AppConstants } = ChromeUtils.importESModule(
"resource://gre/modules/AppConstants.sys.mjs"
);
const AUDIO_TOGGLE_ENABLED_PREF =
"media.videocontrols.picture-in-picture.audio-toggle.enabled";
const KEYBOARD_CONTROLS_ENABLED_PREF =
"media.videocontrols.picture-in-picture.keyboard-controls.enabled";
const CAPTIONS_ENABLED_PREF =
"media.videocontrols.picture-in-picture.display-text-tracks.enabled";
const CAPTIONS_TOGGLE_ENABLED_PREF =
"media.videocontrols.picture-in-picture.display-text-tracks.toggle.enabled";
const TEXT_TRACK_FONT_SIZE_PREF =
"media.videocontrols.picture-in-picture.display-text-tracks.size";
const IMPROVED_CONTROLS_ENABLED_PREF =
"media.videocontrols.picture-in-picture.improved-video-controls.enabled";
const SEETHROUGH_MODE_ENABLED_PREF =
"media.videocontrols.picture-in-picture.seethrough-mode.enabled";
// Time to fade the Picture-in-Picture video controls after first opening.
const CONTROLS_FADE_TIMEOUT_MS = 3000;
const RESIZE_DEBOUNCE_RATE_MS = 500;
/**
Quadrants!
* 2 | 1
* 3 | 4
*/
const TOP_RIGHT_QUADRANT = 1;
const TOP_LEFT_QUADRANT = 2;
const BOTTOM_LEFT_QUADRANT = 3;
const BOTTOM_RIGHT_QUADRANT = 4;
/**
* Public function to be called from PictureInPicture.sys.mjs. This is the main
* entrypoint for initializing the player window.
*
* @param {Number} id
* A unique numeric ID for the window, used for Telemetry Events.
* @param {WindowGlobalParent} wgp
* The WindowGlobalParent that is hosting the originating video.
* @param {ContentDOMReference} videoRef
* A reference to the video element that a Picture-in-Picture window
* is being created for
*/
function setupPlayer(id, wgp, videoRef, autoFocus) {
Player.init(id, wgp, videoRef, autoFocus);
}
/**
* Public function to be called from PictureInPicture.sys.mjs. This update the
* controls based on whether or not the video is playing.
*
* @param {Boolean} isPlaying
* True if the Picture-in-Picture video is playing.
*/
function setIsPlayingState(isPlaying) {
Player.isPlaying = isPlaying;
}
/**
* Public function to be called from PictureInPicture.sys.mjs. This update the
* controls based on whether or not the video is muted.
*
* @param {Boolean} isMuted
* True if the Picture-in-Picture video is muted.
*/
function setIsMutedState(isMuted) {
Player.isMuted = isMuted;
}
/**
* Function to resize and reposition the PiP window
* @param {Object} rect
* An object containing `left`, `top`, `width`, and `height` for the PiP
* window
*/
function resizeToVideo(rect) {
Player.resizeToVideo(rect);
}
/**
* Returns an object containing `left`, `top`, `width`, and `height` of the
* PiP window before entering fullscreen. Will be null if the PiP window is
* not in fullscreen.
*/
function getDeferredResize() {
return Player.deferredResize;
}
function enableSubtitlesButton() {
Player.enableSubtitlesButton();
}
function disableSubtitlesButton() {
Player.disableSubtitlesButton();
}
function setScrubberPosition(position) {
Player.setScrubberPosition(position);
}
function setTimestamp(timeString) {
Player.setTimestamp(timeString);
}
function setVolume(volume) {
Player.setVolume(volume);
}
function closeFromForeground() {
Player.closeFromForeground();
}
/**
* The Player object handles initializing the player, holds state, and handles
* events for updating state.
*/
let Player = {
_isInitialized: false,
WINDOW_EVENTS: [
"click",
"contextmenu",
"dblclick",
"keydown",
"mouseup",
"mousemove",
"MozDOMFullscreen:Entered",
"MozDOMFullscreen:Exited",
"resize",
"unload",
"draggableregionleftmousedown",
],
actor: null,
/**
* Used for resizing Telemetry to avoid recording an event for every resize
* event. Instead, we wait until RESIZE_DEBOUNCE_RATE_MS has passed since the
* last resize event before recording.
*/
resizeDebouncer: null,
/**
* Used for Telemetry to identify the window.
*/
id: -1,
/**
* When set to a non-null value, a timer is scheduled to hide the controls
* after CONTROLS_FADE_TIMEOUT_MS milliseconds.
*/
showingTimeout: null,
/**
* Used to determine old window location when mouseup-ed for corner
* snapping drag vector calculation
*/
oldMouseUpWindowX: window.screenX,
oldMouseUpWindowY: window.screenY,
/**
* Used to determine if hovering the mouse cursor over the pip window or not.
* Gets updated whenever a new hover state is detected.
*/
isCurrentHover: false,
/**
* Store the size and position of the window before entering fullscreen and
* use this to correctly position the window when exiting fullscreen
*/
deferredResize: null,
/**
* Initializes the player browser, and sets up the initial state.
*
* @param {Number} id
* A unique numeric ID for the window, used for Telemetry Events.
* @param {WindowGlobalParent} wgp
* The WindowGlobalParent that is hosting the originating video.
* @param {ContentDOMReference} videoRef
* A reference to the video element that a Picture-in-Picture window
* is being created for
* @param {boolean} autoFocus
* Autofocus the PiP window
*/
init(id, wgp, videoRef, autoFocus) {
this.id = id;
// State for whether or not we are adjusting the time via the scrubber
this.scrubbing = false;
let holder = document.querySelector(".player-holder");
let browser = document.getElementById("browser");
browser.remove();
browser.setAttribute("nodefaultsrc", "true");
this.setupTooltip("close", "pictureinpicture-close-btn", "closeShortcut");
let strId = this.isFullscreen
? `pictureinpicture-exit-fullscreen-btn2`
: `pictureinpicture-fullscreen-btn2`;
this.setupTooltip("fullscreen", strId, "fullscreenToggleShortcut");
// Set the specific remoteType and browsingContextGroupID to use for the
// initial about:blank load. The combination of these two properties will
// ensure that the browser loads in the same process as our originating
// browser.
browser.setAttribute("remoteType", wgp.domProcess.remoteType);
browser.setAttribute(
"initialBrowsingContextGroupId",
wgp.browsingContext.group.id
);
holder.appendChild(browser);
this.actor =
browser.browsingContext.currentWindowGlobal.getActor("PictureInPicture");
this.actor.sendAsyncMessage("PictureInPicture:SetupPlayer", {
videoRef,
});
PictureInPicture.weakPipToWin.set(this.actor, window);
for (let eventType of this.WINDOW_EVENTS) {
addEventListener(eventType, this);
}
this.controls.addEventListener("mouseleave", () => {
this.onMouseLeave();
});
this.controls.addEventListener("mouseenter", () => {
this.onMouseEnter();
});
this.scrubber.addEventListener("input", event => {
this.handleScrubbing(event);
});
this.scrubber.addEventListener("change", event => {
this.handleScrubbingDone(event);
});
this.audioScrubber.addEventListener("input", event => {
this.audioScrubbing = true;
this.handleAudioScrubbing(event.target.value);
});
this.audioScrubber.addEventListener("change", () => {
this.audioScrubbing = false;
});
this.audioScrubber.addEventListener("pointerdown", () => {
if (this.isMuted) {
this.audioScrubber.max = 1;
}
});
for (let radio of document.querySelectorAll(
'input[type=radio][name="cc-size"]'
)) {
radio.addEventListener("change", event => {
this.onSubtitleChange(event.target.id);
});
}
document
.querySelector("#subtitles-toggle")
.addEventListener("change", () => {
this.onToggleChange();
});
// If the content process hosting the video crashes, let's
// just close the window for now.
browser.addEventListener("oop-browser-crashed", this);
this.revealControls(false);
if (Services.prefs.getBoolPref(AUDIO_TOGGLE_ENABLED_PREF, false)) {
const audioButton = document.getElementById("audio");
audioButton.hidden = false;
const audioScrubber = document.getElementById("audio-scrubber");
audioScrubber.hidden = false;
}
if (Services.prefs.getBoolPref(CAPTIONS_ENABLED_PREF, false)) {
this.closedCaptionButton.hidden = false;
}
if (Services.prefs.getBoolPref(IMPROVED_CONTROLS_ENABLED_PREF, false)) {
const fullscreenButton = document.getElementById("fullscreen");
fullscreenButton.hidden = false;
const seekBackwardButton = document.getElementById("seekBackward");
seekBackwardButton.hidden = false;
const seekForwardButton = document.getElementById("seekForward");
seekForwardButton.hidden = false;
this.scrubber.hidden = false;
this.timestamp.hidden = false;
const controlsBottomGradient = document.getElementById(
"controls-bottom-gradient"
);
controlsBottomGradient.hidden = false;
}
this.alignEndControlsButtonTooltips();
this.resizeDebouncer = new DeferredTask(() => {
this.alignEndControlsButtonTooltips();
this.recordEvent("resize", {
width: window.outerWidth,
height: window.outerHeight,
});
}, RESIZE_DEBOUNCE_RATE_MS);
this.computeAndSetMinimumSize(window.outerWidth, window.outerHeight);
// alwaysontop windows are not focused by default, so we have to do it
// ourselves. We use requestAnimationFrame since we have to wait until the
// window is visible before it can focus.
if (autoFocus) {
window.requestAnimationFrame(() => {
window.focus();
});
}
let fontSize = Services.prefs.getCharPref(
TEXT_TRACK_FONT_SIZE_PREF,
"medium"
);
// fallback to medium if the pref value is not a valid option
if (fontSize === "small" || fontSize === "large") {
document.querySelector(`#${fontSize}`).checked = "true";
} else {
document.querySelector("#medium").checked = "true";
}
// In see-through mode the PiP window is made semi-transparent on hover.
if (Services.prefs.getBoolPref(SEETHROUGH_MODE_ENABLED_PREF, false)) {
document.documentElement.classList.add("seethrough-mode");
}
this._isInitialized = true;
},
uninit() {
this.resizeDebouncer.disarm();
PictureInPicture.unload(window, this.actor);
},
setupTooltip(elId, l10nId, shortcutId) {
const el = document.getElementById(elId);
const shortcut = document.getElementById(shortcutId);
let l10nObj = shortcut
? { shortcut: ShortcutUtils.prettifyShortcut(shortcut) }
: {};
document.l10n.setAttributes(el, l10nId, l10nObj);
},
handleEvent(event) {
switch (event.type) {
case "click": {
// Don't run onClick if middle or right click is pressed respectively
if (event.button !== 1 && event.button !== 2) {
this.onClick(event);
this.controls.removeAttribute("keying");
}
break;
}
case "contextmenu": {
event.preventDefault();
break;
}
case "dblclick": {
this.onDblClick(event);
break;
}
case "keydown": {
if (event.keyCode == KeyEvent.DOM_VK_TAB) {
this.controls.setAttribute("keying", true);
this.showVideoControls();
} else if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) {
let isSettingsPanelInFocus = this.settingsPanel.contains(
document.activeElement
);
event.preventDefault();
if (!this.settingsPanel.classList.contains("hide")) {
// If the subtitles settings panel is open, let the ESC key close it
this.toggleSubtitlesSettingsPanel({ forceHide: true });
if (isSettingsPanelInFocus) {
document.getElementById("closed-caption").focus();
}
} else if (this.isFullscreen) {
// We handle the ESC key, in fullscreen modus as intent to leave only the fullscreen mode
document.exitFullscreen();
} else {
// We handle the ESC key, as an intent to leave the picture-in-picture modus
this.onClose();
}
} else if (
Services.prefs.getBoolPref(KEYBOARD_CONTROLS_ENABLED_PREF, false) &&
(event.keyCode != KeyEvent.DOM_VK_SPACE || !event.target.id)
) {
// Pressing "space" fires a "keydown" event which can also trigger a control
// button's "click" event. Handle the "keydown" event only when the event did
// not originate from a control button and it is not a "space" keypress.
this.onKeyDown(event);
}
break;
}
case "mouseup": {
this.onMouseUp(event);
break;
}
case "mousemove": {
this.onMouseMove();
break;
}
// Normally, the DOMFullscreenParent / DOMFullscreenChild actors
// would take care of firing the `fullscreen-painted` notification,
// however, those actors are only ever instantiated when a <browser>
// is fullscreened, and not a <body> element in a parent-process
// chrome privileged DOM window.
//
// Rather than trying to re-engineer JSWindowActors to be re-usable for
// this edge-case, we do the work of firing fullscreen-painted when
// transitioning in and out of fullscreen ourselves here.
case "MozDOMFullscreen:Entered":
// Intentional fall-through
case "MozDOMFullscreen:Exited": {
let { lastTransactionId } = window.windowUtils;
window.addEventListener("MozAfterPaint", function onPainted(event) {
if (event.transactionId > lastTransactionId) {
window.removeEventListener("MozAfterPaint", onPainted);
Services.obs.notifyObservers(window, "fullscreen-painted");
}
});
// If we are exiting fullscreen we want to resize the window to the
// stored size and position
if (this.deferredResize && event.type === "MozDOMFullscreen:Exited") {
this.resizeToVideo(this.deferredResize);
this.deferredResize = null;
}
// Sets the title for fullscreen button when PIP is in Enter Fullscreen mode and Exit Fullscreen mode
let strId = this.isFullscreen
? `pictureinpicture-exit-fullscreen-btn2`
: `pictureinpicture-fullscreen-btn2`;
this.setupTooltip("fullscreen", strId, "fullscreenToggleShortcut");
window.focus();
if (this.isFullscreen) {
this.actor.sendAsyncMessage("PictureInPicture:EnterFullscreen", {
isFullscreen: true,
isVideoControlsShowing: null,
playerBottomControlsDOMRect: null,
});
} else {
this.actor.sendAsyncMessage("PictureInPicture:ExitFullscreen", {
isFullscreen: this.isFullscreen,
isVideoControlsShowing:
!!this.controls.getAttribute("showing") ||
!!this.controls.getAttribute("keying"),
playerBottomControlsDOMRect:
this.controlsBottom.getBoundingClientRect(),
});
}
// The subtitles settings panel gets selected when entering/exiting fullscreen even though
// user-select is set to none. I don't know why this happens or how to prevent so we just
// remove the selection when fullscreen is entered/exited.
let selection = window.getSelection();
selection.removeAllRanges();
break;
}
case "oop-browser-crashed": {
this.closePipWindow({ reason: "BrowserCrash" });
break;
}
case "resize": {
this.onResize(event);
break;
}
case "unload": {
this.uninit();
break;
}
case "draggableregionleftmousedown": {
this.toggleSubtitlesSettingsPanel({ forceHide: true });
break;
}
}
},
/**
* This function handles when the scrubber is being scrubbed by the mouse
* because if we get an input event from the keyboard, onKeyDown will set
* this.preventNextInputEvent to true.
* This function is called by input events on the scrubber
* @param {Event} event The input event
*/
handleScrubbing(event) {
// When using the keyboard to scrub, we get both a keydown and an input
// event. The input event is fired after the keydown and we have already
// handled the keydown event in onKeyDown so we set preventNextInputEvent
// to true in onKeyDown as to not set the current time twice.
if (this.preventNextInputEvent) {
this.preventNextInputEvent = false;
return;
}
if (!this.scrubbing) {
this.wasPlaying = this.isPlaying;
if (this.isPlaying) {
this.actor.sendAsyncMessage("PictureInPicture:Pause");
}
this.scrubbing = true;
}
let scrubberPosition = this.getScrubberPositionFromEvent(event);
this.setVideoTime(scrubberPosition);
},
/**
* This function handles setting the scrubbing state to false and playing
* the video if we paused it before scrubbing.
* @param {Event} event The change event
*/
handleScrubbingDone(event) {
if (!this.scrubbing) {
return;
}
let scrubberPosition = this.getScrubberPositionFromEvent(event);
this.setVideoTime(scrubberPosition);
if (this.wasPlaying) {
this.actor.sendAsyncMessage("PictureInPicture:Play");
}
this.scrubbing = false;
},
/**
* Set the volume on the video and unmute if the video was muted.
* If the volume is changed via the keyboard, onKeyDown will set
* this.preventNextInputEvent to true.
* @param {Number} volume A number between 0 and 1 that represents the volume
*/
handleAudioScrubbing(volume) {
// When using the keyboard to adjust the volume, we get both a keydown and
// an input event. The input event is fired after the keydown event and we
// have already handled the keydown event in onKeyDown so we set
// preventNextInputEvent to true in onKeyDown as to not set the volume twice.
if (this.preventNextInputEvent) {
this.preventNextInputEvent = false;
return;
}
if (this.isMuted) {
this.isMuted = false;
this.actor.sendAsyncMessage("PictureInPicture:Unmute");
}
if (volume == 0) {
this.actor.sendAsyncMessage("PictureInPicture:Mute");
}
this.actor.sendAsyncMessage("PictureInPicture:SetVolume", {
volume,
});
},
getScrubberPositionFromEvent(event) {
return event.target.value;
},
setVideoTime(scrubberPosition) {
let wasPlaying = this.scrubbing ? this.wasPlaying : this.isPlaying;
this.setScrubberPosition(scrubberPosition);
this.actor.sendAsyncMessage("PictureInPicture:SetVideoTime", {
scrubberPosition,
wasPlaying,
});
},
setScrubberPosition(value) {
this.scrubber.value = value;
this.scrubber.hidden = value === undefined;
// Also hide the seek buttons when we hide the scrubber
this.seekBackward.hidden = value === undefined;
this.seekForward.hidden = value === undefined;
},
setTimestamp(timestamp) {
this.timestamp.textContent = timestamp;
this.timestamp.hidden = timestamp === undefined;
},
setVolume(volume) {
if (volume < Number.EPSILON) {
this.actor.sendAsyncMessage("PictureInPicture:Mute");
}
this.audioScrubber.value = volume;
},
closePipWindow(closeData) {
// Set the subtitles font size prefs
Services.prefs.setBoolPref(
CAPTIONS_TOGGLE_ENABLED_PREF,
document.querySelector("#subtitles-toggle").checked
);
for (let radio of document.querySelectorAll(
'input[type=radio][name="cc-size"]'
)) {
if (radio.checked) {
Services.prefs.setCharPref(TEXT_TRACK_FONT_SIZE_PREF, radio.id);
break;
}
}
const { reason } = closeData;
PictureInPicture.closeSinglePipWindow({ reason, actorRef: this.actor });
},
onDblClick(event) {
if (event.target.id == "controls") {
this.fullscreenModeToggle();
event.preventDefault();
}
},
onClick(event) {
switch (event.target.id) {
case "audio": {
this.toggleMute();
break;
}
case "close": {
this.onClose();
break;
}
case "playpause": {
if (!this.isPlaying) {
this.actor.sendAsyncMessage("PictureInPicture:Play");
this.revealControls(false);
} else {
this.actor.sendAsyncMessage("PictureInPicture:Pause");
this.revealControls(true);
}
break;
}
case "seekBackward": {
this.actor.sendAsyncMessage("PictureInPicture:SeekBackward");
break;
}
case "seekForward": {
this.actor.sendAsyncMessage("PictureInPicture:SeekForward");
break;
}
case "unpip": {
PictureInPicture.focusTabAndClosePip(window, this.actor);
break;
}
case "closed-caption": {
let options = {};
if (event.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
options.isKeyboard = true;
}
this.toggleSubtitlesSettingsPanel(options);
// Early return to prevent hiding the panel below
return;
}
case "fullscreen": {
this.fullscreenModeToggle();
this.recordEvent("fullscreen", {
enter: !this.isFullscreen,
});
break;
}
case "font-size-selection-radio-small": {
document.getElementById("small").click();
break;
}
case "font-size-selection-radio-medium": {
document.getElementById("medium").click();
break;
}
case "font-size-selection-radio-large": {
document.getElementById("large").click();
break;
}
}
// If the click came from a element that is not inside the subtitles settings panel
// then we want to hide the panel
if (!this.settingsPanel.contains(event.target)) {
this.toggleSubtitlesSettingsPanel({ forceHide: true });
}
},
/**
* Function to toggle the visibility of the subtitles settings panel
* @param {Object} options [optional] Object containing options for the function
* - forceHide: true to force hide the subtitles settings panel
* - isKeyboard: true if the subtitles button was activated using the keyboard
* to show or hide the subtitles settings panel
*/
toggleSubtitlesSettingsPanel(options) {
let settingsPanelVisible = !this.settingsPanel.classList.contains("hide");
if (options?.forceHide || settingsPanelVisible) {
this.settingsPanel.classList.add("hide");
this.closedCaptionButton.setAttribute("aria-expanded", false);
this.controls.removeAttribute("donthide");
if (
this.controls.getAttribute("keying") ||
this.isCurrentHover ||
this.controls.getAttribute("showing")
) {
return;
}
this.hideVideoControls();
} else {
this.settingsPanel.classList.remove("hide");
this.closedCaptionButton.setAttribute("aria-expanded", true);
this.controls.setAttribute("donthide", true);
this.showVideoControls();
if (options?.isKeyboard) {
document.querySelector("#subtitles-toggle").focus();
}
}
},
onClose() {
this.actor.sendAsyncMessage("PictureInPicture:Pause", {
reason: "pip-closed",
});
this.closePipWindow({ reason: "CloseButton" });
},
closeFromForeground() {
PictureInPicture.closeSinglePipWindow({
reason: "Foregrounded",
actorRef: this.actor,
});
},
fullscreenModeToggle() {
if (this.isFullscreen) {
document.exitFullscreen();
} else {
this.deferredResize = {
left: window.screenX,
top: window.screenY,
width: window.outerWidth,
height: window.outerHeight,
};
document.body.requestFullscreen();
}
},
/**
* Toggle the mute state of the video
*/
toggleMute() {
if (this.isMuted) {
// We unmute in handleAudioScrubbing so no need to also do it here
this.audioScrubber.max = 1;
this.handleAudioScrubbing(this.lastVolume ?? 1);
} else {
this.lastVolume = this.audioScrubber.value;
this.actor.sendAsyncMessage("PictureInPicture:Mute");
}
},
resizeToVideo(rect) {
if (this.isFullscreen) {
// We store the size and position because resizing the PiP window
// while fullscreened will cause issues
this.deferredResize = rect;
} else {
let { left, top, width, height } = rect;
window.resizeTo(width, height);
window.moveTo(left, top);
}
},
onKeyDown(event) {
// We don't want to send a keydown event if the event target was one of the
// font sizes in the settings panel
if (
event.target.parentElement?.parentElement?.classList?.contains(
"font-size-selection"
)
) {
return;
}
let eventKeys = {
altKey: event.altKey,
shiftKey: event.shiftKey,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
keyCode: event.keyCode,
};
// If the up or down arrow is pressed while the scrubber is focused then we
// want to hijack these keydown events to act as left or right arrows
// respectively to correctly seek the video.
if (
event.target.id === "scrubber" &&
event.keyCode === window.KeyEvent.DOM_VK_UP
) {
eventKeys.keyCode = window.KeyEvent.DOM_VK_RIGHT;
} else if (
event.target.id === "scrubber" &&
event.keyCode === window.KeyEvent.DOM_VK_DOWN
) {
eventKeys.keyCode = window.KeyEvent.DOM_VK_LEFT;
}
// If the left or right arrow is pressed while the audio scrubber is focused
// then we want to hijack these keydown events to act as up or down arrows
// respectively to correctly change the volume.
if (
event.target.id === "audio-scrubber" &&
event.keyCode === window.KeyEvent.DOM_VK_RIGHT
) {
eventKeys.keyCode = window.KeyEvent.DOM_VK_UP;
} else if (
event.target.id === "audio-scrubber" &&
event.keyCode === window.KeyEvent.DOM_VK_LEFT
) {
eventKeys.keyCode = window.KeyEvent.DOM_VK_DOWN;
}
// If the keydown event was one of the arrow keys and the scrubber or the
// audio scrubber was focused then we want to prevent the subsequent input
// event from overwriting the keydown event.
if (
event.target.id === "audio-scrubber" ||
(event.target.id === "scrubber" &&
[
window.KeyEvent.DOM_VK_LEFT,
window.KeyEvent.DOM_VK_RIGHT,
window.KeyEvent.DOM_VK_UP,
window.KeyEvent.DOM_VK_DOWN,
].includes(event.keyCode))
) {
this.preventNextInputEvent = true;
}
this.actor.sendAsyncMessage("PictureInPicture:KeyDown", eventKeys);
},
onSubtitleChange(size) {
Services.prefs.setCharPref(TEXT_TRACK_FONT_SIZE_PREF, size);
this.actor.sendAsyncMessage("PictureInPicture:ChangeFontSizeTextTracks");
},
onToggleChange() {
// The subtitles toggle has been click in the settings panel so we toggle
// the overlay above the font sizes and send a message to toggle the
// visibility of the subtitles and set the toggle pref
document
.querySelector(".font-size-selection")
.classList.toggle("font-size-overlay");
this.actor.sendAsyncMessage("PictureInPicture:ToggleTextTracks");
this.captionsToggleEnabled = !this.captionsToggleEnabled;
Services.prefs.setBoolPref(
CAPTIONS_TOGGLE_ENABLED_PREF,
this.captionsToggleEnabled
);
},
/**
* PiP Corner Snapping Helper Function
* Determines the quadrant the PiP window is currently in.
*/
determineCurrentQuadrant() {
// Determine center coordinates of window.
let windowCenterX = window.screenX + window.outerWidth / 2;
let windowCenterY = window.screenY + window.outerHeight / 2;
let quadrant = null;
let halfWidth = window.screen.availLeft + window.screen.availWidth / 2;
let halfHeight = window.screen.availTop + window.screen.availHeight / 2;
let leftHalf = windowCenterX < halfWidth;
let rightHalf = windowCenterX > halfWidth;
let topHalf = windowCenterY < halfHeight;
let bottomHalf = windowCenterY > halfHeight;
if (leftHalf && topHalf) {
quadrant = TOP_LEFT_QUADRANT;
} else if (rightHalf && topHalf) {
quadrant = TOP_RIGHT_QUADRANT;
} else if (leftHalf && bottomHalf) {
quadrant = BOTTOM_LEFT_QUADRANT;
} else if (rightHalf && bottomHalf) {
quadrant = BOTTOM_RIGHT_QUADRANT;
}
return quadrant;
},
/**
* Helper function to actually move/snap the PiP window.
* Moves the PiP window to the top right.
*/
moveToTopRight() {
window.moveTo(
window.screen.availLeft + window.screen.availWidth - window.innerWidth,
window.screen.availTop
);
},
/**
* Moves the PiP window to the top left.
*/
moveToTopLeft() {
window.moveTo(window.screen.availLeft, window.screen.availTop);
},
/**
* Moves the PiP window to the bottom right.
*/
moveToBottomRight() {
window.moveTo(
window.screen.availLeft + window.screen.availWidth - window.innerWidth,
window.screen.availTop + window.screen.availHeight - window.innerHeight
);
},
/**
* Moves the PiP window to the bottom left.
*/
moveToBottomLeft() {
window.moveTo(
window.screen.availLeft,
window.screen.availTop + window.screen.availHeight - window.innerHeight
);
},
/**
* Uses the PiP window's change in position to determine which direction
* the window has been moved in.
*/
determineDirectionDragged() {
// Determine change in window location.
let deltaX = this.oldMouseUpWindowX - window.screenX;
let deltaY = this.oldMouseUpWindowY - window.screenY;
let dragDirection = "";
if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX < 0) {
dragDirection = "draggedRight";
} else if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX > 0) {
dragDirection = "draggedLeft";
} else if (Math.abs(deltaX) < Math.abs(deltaY) && deltaY < 0) {
dragDirection = "draggedDown";
} else if (Math.abs(deltaX) < Math.abs(deltaY) && deltaY > 0) {
dragDirection = "draggedUp";
}
return dragDirection;
},
/**
* Event handler for "mouseup" events on the PiP window.
*
* @param {Event} event
* Event context details
*/
onMouseUp(event) {
// Corner snapping changes start here.
// Check if metakey pressed and macOS
let quadrant = this.determineCurrentQuadrant();
let dragAction = this.determineDirectionDragged();
if (
((event.ctrlKey && AppConstants.platform !== "macosx") ||
(event.metaKey && AppConstants.platform === "macosx")) &&
dragAction
) {
// Moving logic based on current quadrant and direction of drag.
switch (quadrant) {
case TOP_RIGHT_QUADRANT:
switch (dragAction) {
case "draggedRight":
this.moveToTopRight();
break;
case "draggedLeft":
this.moveToTopLeft();
break;
case "draggedDown":
this.moveToBottomRight();
break;
case "draggedUp":
this.moveToTopRight();
break;
}
break;
case TOP_LEFT_QUADRANT:
switch (dragAction) {
case "draggedRight":
this.moveToTopRight();
break;
case "draggedLeft":
this.moveToTopLeft();
break;
case "draggedDown":
this.moveToBottomLeft();
break;
case "draggedUp":
this.moveToTopLeft();
break;
}
break;
case BOTTOM_LEFT_QUADRANT:
switch (dragAction) {
case "draggedRight":
this.moveToBottomRight();
break;
case "draggedLeft":
this.moveToBottomLeft();
break;
case "draggedDown":
this.moveToBottomLeft();
break;
case "draggedUp":
this.moveToTopLeft();
break;
}
break;
case BOTTOM_RIGHT_QUADRANT:
switch (dragAction) {
case "draggedRight":
this.moveToBottomRight();
break;
case "draggedLeft":
this.moveToBottomLeft();
break;
case "draggedDown":
this.moveToBottomRight();
break;
case "draggedUp":
this.moveToTopRight();
break;
}
break;
} // Switch close.
} // Metakey close.
this.oldMouseUpWindowX = window.screenX;
this.oldMouseUpWindowY = window.screenY;
},
/**
* Event handler for mousemove the PiP Window
*/
onMouseMove() {
if (this.isFullscreen) {
this.revealControls(false);
}
},
onMouseEnter() {
if (!this.isFullscreen) {
this.isCurrentHover = true;
this.showVideoControls();
}
},
onMouseLeave() {
if (!this.isFullscreen) {
this.isCurrentHover = false;
if (
!this.controls.getAttribute("showing") &&
!this.controls.getAttribute("keying") &&
!this.controls.getAttribute("donthide")
) {
this.hideVideoControls();
}
}
},
enableSubtitlesButton() {
this.closedCaptionButton.disabled = false;
this.alignEndControlsButtonTooltips();
this.captionsToggleEnabled = true;
// If the CAPTIONS_TOGGLE_ENABLED_PREF pref is false then we will click
// the UI toggle to change the toggle to unchecked. This will call
// onToggleChange where this.captionsToggleEnabled will be updated
if (!Services.prefs.getBoolPref(CAPTIONS_TOGGLE_ENABLED_PREF, true)) {
document.querySelector("#subtitles-toggle").click();
}
},
disableSubtitlesButton() {
this.closedCaptionButton.disabled = true;
this.alignEndControlsButtonTooltips();
},
/**
* Sets focus state inline end tooltip for rightmost playback controls
*/
alignEndControlsButtonTooltips() {
let audioBtn = document.getElementById("audio");
let width = window.outerWidth;
if (300 < width && width <= 400) {
audioBtn.classList.replace("center-tooltip", "inline-end-tooltip");
} else {
audioBtn.classList.replace("inline-end-tooltip", "center-tooltip");
}
},
/**
* Event handler for resizing the PiP Window
*
* @param {Event} event
* Event context data object
*/
onResize() {
this.toggleSubtitlesSettingsPanel({ forceHide: true });
this.resizeDebouncer.disarm();
this.resizeDebouncer.arm();
},
/**
* Event handler for user issued commands
*
* @param {Event} event
* Event context data object
*/
onCommand() {
this.closePipWindow({ reason: "Shortcut" });
},
get controls() {
delete this.controls;
return (this.controls = document.getElementById("controls"));
},
get scrubber() {
delete this.scrubber;
return (this.scrubber = document.getElementById("scrubber"));
},
get audioScrubber() {
delete this.audioScrubber;
return (this.audioScrubber = document.getElementById("audio-scrubber"));
},
get timestamp() {
delete this.timestamp;
return (this.timestamp = document.getElementById("timestamp"));
},
get controlsBottom() {
delete this.controlsBottom;
return (this.controlsBottom = document.getElementById("controls-bottom"));
},
get seekBackward() {
delete this.seekBackward;
return (this.seekBackward = document.getElementById("seekBackward"));
},
get seekForward() {
delete this.seekForward;
return (this.seekForward = document.getElementById("seekForward"));
},
get closedCaptionButton() {
delete this.closedCaptionButton;
return (this.closedCaptionButton =
document.getElementById("closed-caption"));
},
get settingsPanel() {
delete this.settingsPanel;
return (this.settingsPanel = document.getElementById("settings"));
},
_isPlaying: false,
/**
* GET isPlaying returns true if the video is currently playing.
*
* SET isPlaying to true if the video is playing, false otherwise. This will
* update the internal state and displayed controls.
*
* @type {Boolean}
*/
get isPlaying() {
return this._isPlaying;
},
set isPlaying(isPlaying) {
this._isPlaying = isPlaying;
this.controls.classList.toggle("playing", isPlaying);
let strId = isPlaying
? `pictureinpicture-pause-btn`
: `pictureinpicture-play-btn`;
this.setupTooltip("playpause", strId);
if (
!this._isInitialized ||
this.isCurrentHover ||
this.controls.getAttribute("keying")
) {
return;
}
if (!isPlaying) {
this.revealControls(true);
} else {
this.revealControls(false);
}
},
_isMuted: false,
/**
* GET isMuted returns true if the video is currently muted.
*
* SET isMuted to true if the video is muted, false otherwise. This will
* update the internal state and displayed controls.
*
* @type {Boolean}
*/
get isMuted() {
return this._isMuted;
},
set isMuted(isMuted) {
this._isMuted = isMuted;
if (!isMuted) {
this.audioScrubber.max = 1;
} else if (!this.audioScrubbing) {
this.audioScrubber.max = 0;
}
this.controls.classList.toggle("muted", isMuted);
let strId = isMuted
? `pictureinpicture-unmute-btn`
: `pictureinpicture-mute-btn`;
let shortcutId = isMuted ? "unMuteShortcut" : "muteShortcut";
this.setupTooltip("audio", strId, shortcutId);
},
/**
* GET isFullscreen returns true if the video is running in fullscreen mode
*
* @returns {boolean}
*/
get isFullscreen() {
return document.fullscreenElement == document.body;
},
/**
* Used for recording telemetry in Picture-in-Picture.
*
* @param {string} type
* The type of PiP event being recorded.
* @param {object} args
* The data to pass to telemetry when the event is recorded.
*/
recordEvent(type, args) {
args.value = this.id;
Glean.pictureinpicture[type + "Player"].record(args);
},
/**
* Send a message to PiPChild to adjust the subtitles position
* so that subtitles are visible when showing video controls.
*/
showVideoControls() {
// offsetParent returns null when the element or any ancestor has display: none
this.actor.sendAsyncMessage("PictureInPicture:ShowVideoControls", {
isFullscreen: this.isFullscreen,
isVideoControlsShowing: true,
playerBottomControlsDOMRect: this.controlsBottom.getBoundingClientRect(),
isScrubberShowing: !!this.scrubber.offsetParent,
});
},
/**
* Send a message to PiPChild to adjust the subtitles position
* so that subtitles take up remaining space when hiding video controls.
*/
hideVideoControls() {
this.actor.sendAsyncMessage("PictureInPicture:HideVideoControls", {
isFullscreen: this.isFullscreen,
isVideoControlsShowing: false,
playerBottomControlsDOMRect: null,
});
},
/**
* Makes the player controls visible.
*
* @param {Boolean} revealIndefinitely
* If false, this will hide the controls again after
* CONTROLS_FADE_TIMEOUT_MS milliseconds has passed. If true, the controls
* will remain visible until revealControls is called again with
* revealIndefinitely set to false.
*/
revealControls(revealIndefinitely) {
clearTimeout(this.showingTimeout);
this.showingTimeout = null;
this.controls.setAttribute("showing", true);
if (!this.isFullscreen) {
// revealControls() is called everytime we hover over fullscreen pip window.
// Only communicate with pipchild when not in fullscreen mode for performance reasons.
this.showVideoControls();
}
if (!revealIndefinitely) {
this.showingTimeout = setTimeout(() => {
const isHoverOverControlItem = this.controls.querySelector(
".control-item:hover"
);
if (this.isFullscreen && isHoverOverControlItem) {
return;
}
this.controls.removeAttribute("showing");
if (
!this.isFullscreen &&
!this.isCurrentHover &&
!this.controls.getAttribute("keying") &&
!this.controls.getAttribute("donthide")
) {
this.hideVideoControls();
}
}, CONTROLS_FADE_TIMEOUT_MS);
}
},
/**
* Given a width and height for a video, computes the minimum dimensions for
* the player window, and then sets them on the root element.
*
* This is currently only used on Linux GTK, where the OS doesn't already
* impose a minimum window size. For other platforms, this function is a
* no-op.
*
* @param {Number} width
* The width of the video being played.
* @param {Number} height
* The height of the video being played.
*/
computeAndSetMinimumSize(width, height) {
if (!AppConstants.MOZ_WIDGET_GTK) {
return;
}
// Using inspection, these seem to be the right minimums for each dimension
// so that the controls don't get too crowded.
const MIN_WIDTH = 120;
const MIN_HEIGHT = 80;
let resultWidth = width;
let resultHeight = height;
let aspectRatio = width / height;
// Take the smaller of the two dimensions, and set it to the minimum.
// Then calculate the other dimension using the aspect ratio to get
// both minimums.
if (width < height) {
resultWidth = MIN_WIDTH;
resultHeight = Math.round(MIN_WIDTH / aspectRatio);
} else {
resultHeight = MIN_HEIGHT;
resultWidth = Math.round(MIN_HEIGHT * aspectRatio);
}
document.documentElement.style.minWidth = resultWidth + "px";
document.documentElement.style.minHeight = resultHeight + "px";
},
};