VulpesOS/gaia/shared/js/media/video_player.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

617 lines
20 KiB
JavaScript
Executable file

'use strict';
/* global Format */
//
// Create a <video> element and <div> containing a video player UI and
// add them to the specified container. The UI requires a GestureDetector
// to be running for the container or one of its ancestors.
//
// Some devices have only a single hardware video decoder and can only
// have one video tag playing anywhere at once. So this class is careful
// to only load content into a <video> element when the user really wants
// to play it. At other times it displays a poster image for the video.
// Initially, it displays the poster image. Pressing play starts the video.
// Pausing pauses the video but does not revert to the poster. Finishing the
// video reverts to the initial state with the poster image displayed.
// If we get a visiblitychange event saying that we've been hidden, we
// remember the playback position, pause the video take a temporary
// screenshot and display it, and unload the video. If shown again
// and if the user clicks play again, we resume the video where we left off.
//
// WARNING (bug 1135278):
//
// The VideoPlayer object registers event listeners on the window object
// and never unregisters them. This means that VideoPlayer objects will
// never be garbage collected since the window will always have a reference
// to every one that has been created.
//
// A conscious decision has been made to not fix this issue because:
//
// 1) Currently only gallery and camera use the object and they use a
// fixed number of objects whose lifetime is the same as that of the
// app.
// 2) We are moving towards replacing this VideoPlayer object with web
// components (bug 1117885, bug 1131321)
//
function VideoPlayer(container) {
if (typeof container === 'string') {
container = document.getElementById(container);
}
// Add a class to the container so we could find it later and use it as
// a key in the instance weakmap.
container.classList.add('video-player-container');
VideoPlayer.instancesToLocalize.set(container, this);
function newelt(parent, type, classes, l10n_id, attributes) {
var e = document.createElement(type);
if (classes) {
e.className = classes;
}
if (l10n_id) {
e.dataset.l10nId = l10n_id;
}
if (attributes) {
for (var attribute in attributes) {
e.setAttribute(attribute, attributes[attribute]);
}
}
parent.appendChild(e);
return e;
}
// This copies the controls structure of the Video app
var poster = newelt(container, 'img', 'videoPoster');
var player = newelt(container, 'video', 'videoPlayer', null,
{ 'aria-hidden': true }); // Since Video Player controls
// are custom, we need to hide
// the video element to not
// confuse screen reader users.
var controls = newelt(container, 'div', 'videoPlayerControls');
var playbutton = newelt(controls, 'button', 'videoPlayerPlayButton',
'playbackPlay');
var footer = newelt(controls, 'div', 'videoPlayerFooter hidden');
var pausebutton = newelt(footer, 'button', 'videoPlayerPauseButton',
'playbackPause');
var slider = newelt(footer, 'div', 'videoPlayerSlider', null,
{ 'role': 'slider', 'aria-valuemin': 0 });
var elapsedText = newelt(slider, 'span', 'videoPlayerElapsedText');
var progress = newelt(slider, 'div', 'videoPlayerProgress');
var backgroundBar = newelt(progress, 'div', 'videoPlayerBackgroundBar');
var elapsedBar = newelt(progress, 'div', 'videoPlayerElapsedBar');
var playHead = newelt(progress, 'div', 'videoPlayerPlayHead');
var durationText = newelt(slider, 'span', 'videoPlayerDurationText');
// expose fullscreen button, so that client can manipulate it directly
var fullscreenButton = newelt(slider, 'button', 'videoPlayerFullscreenButton',
'playbackFullscreen');
this.poster = poster;
this.player = player;
this.controls = controls;
this.playing = false;
player.preload = 'metadata';
player.mozAudioChannelType = 'content';
var self = this;
var controlsHidden = false;
var dragging = false;
var endedTimer;
var videourl; // the url of the video to play
var posterurl; // the url of the poster image to display
var rotation; // Do we have to rotate the video? Set by load()
var aspectRatio; // Width divided by height (after rotation)
var videotimestamp;
// These are the dimensions at which the poster image and video will
// be displayed on the screen. We make them as big as possible while still
// fitting on the screen, so small videos get scaled up and large videos
// get scaled down. These are the sizes that the user actually sees. So
// if the video is rotated 90 or 270 degrees, then the img and video
// elements will have these dimension swapped.
var playbackWidth, playbackHeight;
var playbackTime;
var capturedFrame;
this.load = function(video, posterimage, aspect, rotate, timestamp) {
this.reset();
videourl = video;
posterurl = posterimage;
rotation = rotate || 0;
// We don't need to know what the actual resolution of the video
// or the poster image is. We just need to know its aspect ratio.
// Both the video and the poster *must* have the same ratio. Note
// that the aspect ratio passed to load() is raw width/height
// without considering rotation. But we take the reciprocal here
// if necessary to account for rotation.
if (rotation === 0 || rotation === 180) {
aspectRatio = aspect;
}
else {
aspectRatio = 1 / aspect;
}
videotimestamp = timestamp;
this.localize();
this.init();
setPlayerSize();
};
this.reset = function() {
videourl = null;
posterurl = null;
rotation = null;
aspectRatio = null;
videotimestamp = 0;
hidePlayer();
hidePoster();
};
this.init = function() {
playbackTime = 0;
hidePlayer();
showPoster();
this.pause();
};
function hidePlayer() {
player.style.display = 'none';
player.removeAttribute('src');
player.load();
self.playerShowing = false;
}
function showPlayer() {
if (self.onloading) {
self.onloading();
}
player.style.display = 'block';
player.src = videourl;
self.playerShowing = true;
// The only place we call showPlayer() is from the play() function.
// If play() has to show the player, call it again when we're ready to play.
player.oncanplay = function() {
player.oncanplay = null;
if (playbackTime !== 0) {
player.currentTime = playbackTime;
}
self.play();
};
}
function hidePoster() {
poster.style.display = 'none';
poster.removeAttribute('src');
if (capturedFrame) {
URL.revokeObjectURL(capturedFrame);
capturedFrame = null;
}
}
function showPoster() {
poster.style.display = 'block';
if (capturedFrame) {
poster.src = capturedFrame;
}
else {
poster.src = posterurl;
}
}
// Call this when the container size changes
this.setPlayerSize = setPlayerSize;
this.pause = function pause() {
// Pause video playback
if (self.playerShowing) {
this.playing = false;
player.pause();
}
// Hide the pause button and slider
footer.classList.add('hidden');
controlsHidden = true;
// Show the big central play button
playbutton.classList.remove('hidden');
if (this.onpaused) {
this.onpaused();
}
};
// Set up the playing state
this.play = function play() {
if (!this.playerShowing) {
// If we're displaying the poster image, we have to switch
// to the player first. When the player is ready it wil call this
// function again.
hidePoster();
showPlayer();
return;
}
// Hide the play button
playbutton.classList.add('hidden');
this.playing = true;
// Start playing the video
player.play();
// Show the controls
footer.classList.remove('hidden');
controlsHidden = false;
if (this.onplaying) {
this.onplaying();
}
};
fullscreenButton.addEventListener('tap', function(e) {
if (self.onfullscreentap) {
// If the event propagate to controller, videoplayer will hide
// the toolbar, so we stopPropagation here.
e.stopPropagation();
self.onfullscreentap();
}
});
// Hook up the play button
playbutton.addEventListener('tap', function(e) {
// If we're not showing the player or are paused, go to the play state
if (!self.playerShowing || player.paused) {
self.play();
}
e.stopPropagation();
});
// Hook up the pause button
pausebutton.addEventListener('tap', function(e) {
self.pause();
e.stopPropagation();
});
// A click anywhere else on the screen should toggle the footer
// But only when the video is playing.
container.addEventListener('tap', function(e) {
if ((e.target === player || e.target === container) && !player.paused) {
footer.classList.toggle('hidden');
controlsHidden = !controlsHidden;
}
});
// Set the video duration and size when we get metadata
player.onloadedmetadata = function() {
var formattedTime = formatTime(player.duration);
durationText.textContent = formattedTime;
slider.setAttribute('aria-valuemax', player.duration);
// This sets the aria-label to a localized slider description
document.l10n.setAttributes(slider, 'playbackSeekBar',
{'duration': formattedTime});
// start off in the paused state
self.pause();
};
// Also resize the player on a resize event
// (when the user rotates the phone)
window.addEventListener('resize', function() {
setPlayerSize();
});
// If we reach the end of a video, reset to beginning
// This isn't always reliable, so we also set a timer in updateTime()
player.onended = ended;
function ended() {
if (dragging) {
return;
}
if (endedTimer) {
clearTimeout(endedTimer);
endedTimer = null;
}
self.pause();
self.init();
}
// Update the slider and elapsed time as the video plays
player.ontimeupdate = updateTime;
// Set the elapsed time and slider position
function updateTime() {
if (!controlsHidden) {
var formattedTime = formatTime(player.currentTime);
elapsedText.textContent = formattedTime;
slider.setAttribute('aria-valuenow', player.currentTime);
slider.setAttribute('aria-valuetext', formattedTime);
// We can't update a progress bar if we don't know how long
// the video is. It is kind of a bug that the <video> element
// can't figure this out for ogv videos.
if (player.duration === Infinity || player.duration === 0) {
return;
}
var percent = (player.currentTime / player.duration) * 100 + '%';
var startEdge =
document.documentElement.dir === 'ltr' ? 'left' : 'right';
elapsedBar.style.width = percent;
playHead.style[startEdge] = percent;
}
// Since we don't always get reliable 'ended' events, see if
// we've reached the end this way.
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=783512
// If we're within 1 second of the end of the video, register
// a timeout a half a second after we'd expect an ended event.
if (!endedTimer) {
if (!dragging && player.currentTime >= player.duration - 1) {
var timeUntilEnd = (player.duration - player.currentTime + 0.5);
endedTimer = setTimeout(ended, timeUntilEnd * 1000);
}
}
else if (dragging && player.currentTime < player.duration - 1) {
// If there is a timer set and we drag away from the end, cancel the timer
clearTimeout(endedTimer);
endedTimer = null;
}
}
// Pause and unload the video if we're hidden so that other apps
// can use the video decoder hardware.
window.addEventListener('visibilitychange', visibilityChanged);
function visibilityChanged() {
if (document.hidden) {
// If we're just showing the poster image when we're hidden
// then we don't have to do anything special
if (!self.playerShowing) {
return;
}
self.pause();
// If we're not at the beginning of the video, capture a
// temporary poster image to display when we come back
if (player.currentTime !== 0) {
playbackTime = player.currentTime;
captureCurrentFrame(function(blob) {
capturedFrame = URL.createObjectURL(blob);
hidePlayer();
showPoster();
});
}
else {
// Even if we don't capture a frame, hide the video
hidePlayer();
showPoster();
}
}
}
// Capture the current frame as an image that we can use as a poster
// when the app goes to the background. Note that we capture an
// image the size of the screen, not the size of the video. For
// really large-format videos this is much more efficient. However,
// if the user goes to the background, changes orientation and comes back
// it may be that the size we captured is no longer the size to be
// displayed on the screen, and the preview image may be upscaled.
// Since it is just a preview, this should not be a real problem.
function captureCurrentFrame(callback) {
// If there is no video, then there is nothing to do here
if (!player.src) {
return;
}
var canvas = document.createElement('canvas');
canvas.width = playbackWidth;
canvas.height = playbackHeight;
var context = canvas.getContext('2d');
context.drawImage(player, 0, 0, canvas.width, canvas.height);
canvas.toBlob(callback);
}
// Make the poster img element and video element fit the container
function setPlayerSize() {
// If we don't have an aspectRatio, then the load() method has not
// been called yet (or the reset() method has already been called).
// There is nothing we can do in that case (but we might still be called
// because of a resize event handler)
if (!aspectRatio) {
return;
}
// Given the size of the container, and the aspect ratio of the video
// (and of its poster image) we need to figure out the largest possible
// playback size for the video. (Note that he actual size of the video
// and the poster image is does not matter: the img and video elements
// will scale them to the playback size for us.)
//
// If the container aspect ratio is larger than the media aspect ratio
// (i.e. if the container is more of a squashed rectangle than the movie
// is), then the playback size is constrained by the height of the
// container and there will be black bars on the left and right.
// Otherwise the video is more squashed than the container, playback
// dimensions are constrained by the container width, and there will be
// black bars at the top and bottom.
var containerWidth = container.clientWidth;
var containerHeight = container.clientHeight;
if (containerWidth > aspectRatio * containerHeight) {
playbackHeight = containerHeight;
playbackWidth = Math.round(playbackHeight * aspectRatio);
} else {
playbackWidth = containerWidth;
playbackHeight = Math.round(playbackWidth / aspectRatio);
}
// These are the dimensions of the poster and video elements.
// If those elements are going to be rotated before being displayed
// the we've got to swap width and height.
var elementWidth, elementHeight;
if (rotation === 0 || rotation === 180) {
elementWidth = playbackWidth;
elementHeight = playbackHeight;
}
else {
elementWidth = playbackHeight;
elementHeight = playbackWidth;
}
// Now set the size and position of the video player and poster elements.
poster.style.position = player.style.position = 'absolute';
player.style.width = poster.style.width = elementWidth + 'px';
player.style.height = poster.style.height = elementHeight + 'px';
poster.style.left = player.style.left =
((containerWidth - elementWidth) / 2) + 'px';
poster.style.top = player.style.top =
((containerHeight - elementHeight) / 2) + 'px';
// Finally, use a CSS transform to rotate the poster and video as needed
poster.style.transformOrigin = player.style.transformOrigin = '50% 50%';
poster.style.transform = player.style.transform =
'rotate(' + rotation + 'deg)';
}
// handle drags on the time slider
slider.addEventListener('pan', function pan(e) {
e.stopPropagation();
// We can't do anything if we don't know our duration
if (player.duration === Infinity) {
return;
}
if (!dragging) { // Do this stuff on the first pan event only
dragging = true;
}
var rect = backgroundBar.getBoundingClientRect();
var position = (e.detail.position.clientX - rect.left) / rect.width;
var pos = Math.min(Math.max(position, 0), 1);
// Handle pos so that slider moves correct way
// when user drags it for RTL locales
if (document.documentElement.dir === 'rtl') {
pos = 1 - pos;
}
player.currentTime = player.duration * pos;
updateTime();
});
slider.addEventListener('swipe', function swipe(e) {
e.stopPropagation();
dragging = false;
if (player.currentTime >= player.duration) {
self.pause();
}
});
slider.addEventListener('keypress', function(e) {
// The standard accessible control for sliders is arrow up/down keys.
// Our screenreader synthesizes those events on swipe up/down gestures.
// Currently, we only allow screen reader users to adjust sliders with a
// constant step size (there is no page up/down equivalent). In the case
// of videos, we make sure that the maximum amount of steps for the entire
// duration is 20, or 2 second increments if the duration is less then 40
// seconds.
var step = Math.max(player.duration/20, 2);
if (e.keyCode == e.DOM_VK_DOWN) {
player.currentTime -= step;
} else if (e.keyCode == e.DOM_VK_UP) {
player.currentTime += step;
}
});
function formatTime(time) {
time = Math.round(time);
var minutes = Math.floor(time / 60);
var seconds = time % 60;
if (minutes < 60) {
return Format.padLeft(minutes, 2, '0') + ':' +
Format.padLeft(seconds, 2, '0');
} else {
var hours = Math.floor(minutes / 60);
minutes = Math.round(minutes % 60);
return hours + ':' + Format.padLeft(minutes, 2, '0') + ':' +
Format.padLeft(seconds, 2, '0');
}
return '';
}
// pause the video player if user unplugs headphone
var acm = navigator.mozAudioChannelManager;
if (acm) {
acm.addEventListener('headphoneschange', function onheadphoneschange() {
if (!acm.headphones && self.playing) {
self.pause();
}
});
}
this.localize = function() {
// XXX: Ideally, we would add the duration too, but that is not
// available via fileinfo metadata yet.
var portrait = (aspectRatio < 1);
var orientationL10nId = portrait ? 'orientationPortrait' :
'orientationLandscape';
document.l10n.formatValue(orientationL10nId).then((orientationText) => {
if (videotimestamp) {
if (!self.dtf) {
// XXX: add localized/timeformatchange event to reset
self.dtf = Intl.DateTimeFormat(navigator.languages, {
hour12: navigator.mozHour12,
hour: 'numeric',
minute: 'numeric',
day: 'numeric',
month: 'numeric',
year: 'numeric'
});
}
var ts = this.dtf.format(new Date(videotimestamp));
document.l10n.setAttributes(
poster,
'videoDescription',
{
orientation: orientationText,
timestamp: ts
}
);
} else {
document.l10n.setAttributes(
poster,
'videoDescriptionNoTimestamp',
{ orientation: orientationText }
);
}
});
};
}
VideoPlayer.prototype.hide = function() {
// Call reset() to hide the poster and player
this.controls.style.display = 'none';
};
VideoPlayer.prototype.show = function() {
// Call init() to show the poster
this.controls.style.display = 'block';
};
VideoPlayer.instancesToLocalize = new WeakMap();
document.addEventListener('DOMRetranslated', function() {
// Retrieve VideoPlayer instances by searching for container nodes.
for (var container of document.querySelectorAll('.video-player-container')) {
var instance = VideoPlayer.instancesToLocalize.get(container);
if (instance) {
instance.localize();
}
}
});