VulpesOS/assets/webapps/gallery.gaiamobile.org/js/frames.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

715 lines
24 KiB
JavaScript

// This file contains Gallery code related to the fullscreen view
'use strict';
/* global
$,
CONFIG_MAX_IMAGE_PIXEL_SIZE,
CONFIG_MAX_PICK_PIXEL_SIZE,
CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT,
CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH,
cropResizeRotate,
currentFileIndex,
currentView,
deleteFile,
Dialogs,
editPhotoIfCardNotFull,
files,
frames:true,
fullscreenButtons,
fullscreenView,
GestureDetector,
getCurrentFile,
getVideoFile,
isPhone,
LAYOUT_MODE,
LazyLoader,
MediaFrame,
NFC,
photodb,
setView,
share,
showFileInformation,
Spinner,
TRANSITION_FRACTION,
TRANSITION_SPEED,
updateFocusThumbnail
*/
/* exported
clearFrames,
showFile
*/
var frames = $('frames');
// These three objects are holders for the previous, current and next
// photos or videos to be displayed. They get swapped around and
// reused when we pan to the next or previous photo: next becomes
// current, current becomes previous etc. See nextFile() and
// previousFile(). Note also that the Frame object is not a DOM
// element. Use currentFrame.container to refer to the section
// element. The frame constructor creates an <img> element, a <video>
// element, and video player controls within the section, and you can refer to
// those as currentFrame.image and currentFrame.video.player and
// currentFrame.video.controls.
var maxImageSize = CONFIG_MAX_IMAGE_PIXEL_SIZE;
var previousFrame = new MediaFrame($('frame1'), true, maxImageSize);
var currentFrame = new MediaFrame($('frame2'), true, maxImageSize);
var nextFrame = new MediaFrame($('frame3'), true, maxImageSize);
if (CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH) {
previousFrame.setMinimumPreviewSize(CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH,
CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT);
currentFrame.setMinimumPreviewSize(CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH,
CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT);
nextFrame.setMinimumPreviewSize(CONFIG_REQUIRED_EXIF_PREVIEW_WIDTH,
CONFIG_REQUIRED_EXIF_PREVIEW_HEIGHT);
}
// When this variable is set to true, we ignore any user gestures
// so we don't try to pan or zoom during a frame transition.
var transitioning = false;
// Clicking on the back button will go to the preview view
// Note that tablet doesn't have a back button, it's bind to header instead,
// so we don't try to register click event on a non-existent button
if (fullscreenButtons.back) {
fullscreenButtons.back.onclick = setView.bind(null, LAYOUT_MODE.list);
}
// Clicking the delete button while viewing a single item deletes that item
fullscreenButtons.delete.onclick = deleteSingleItem;
// Clicking the Edit button while viewing a photo switches to edit mode
fullscreenButtons.edit.onclick = function() {
LazyLoader.load(['js/ImageEditor.js',
'js/image_processor_thread.js',
'shared/js/media/crop_resize_rotate.js',
'shared/js/component_utils.js',
'shared/elements/gaia_menu/script.js'
], function() {
editPhotoIfCardNotFull(currentFileIndex);
});
};
// In fullscreen mode, the share button shares the current item
fullscreenButtons.share.onclick = shareSingleItem;
// Clicking the information button will display information about the photo.
fullscreenButtons.info.onclick = function() {
LazyLoader.load(['js/info.js', 'shared/style/confirm.css', 'style/info.css'],
function() {
showFileInformation(files[currentFileIndex]);
});
};
// Use the GestureDetector.js library to handle gestures.
// This will generate tap, pan, swipe and transform events
new GestureDetector(frames).startDetecting();
var frameOffset = 0; // how far are the frames swiped side-to-side?
// Handle gesture events
frames.addEventListener('tap', tapHandler);
frames.addEventListener('dbltap', dblTapHandler);
frames.addEventListener('pan', panHandler);
frames.addEventListener('swipe', swipeHandler);
frames.addEventListener('transform', transformHandler);
frames.addEventListener('wheel', wheelHandler);
currentFrame.video.onfullscreentap =
previousFrame.video.onfullscreentap =
nextFrame.video.onfullscreentap =
function fullscreenRequested(ev) {
setView(LAYOUT_MODE.fullscreen);
resizeFrames();
};
// When displaying a photo or video, a tap hides or shows the toolbar.
// The video player has its own toolbar, so when a video starts playing
// we want to hide the gallery toolbar. And then restore it on pause.
// All three players need this pair of event handlers.
// Note that we're using the onplaying/onpaused fake handlers the
// VideoPlayer object, not the real onplay/onpause handlers of the <video>
// element. This is because VideoPlayer pauses and plays the <video> when
// the user drags on the slider, and we don't want to trigger these handlers
// in that case.
currentFrame.video.onplaying =
previousFrame.video.onplaying =
nextFrame.video.onplaying =
function hideToolbarOnPlay() {
this.isToolbarHidden =
fullscreenView.classList.contains('toolbar-hidden');
if (!this.isToolbarHidden) {
fullscreenView.classList.add('toolbar-hidden');
}
};
currentFrame.video.onpaused =
previousFrame.video.onpaused =
nextFrame.video.onpaused =
function restoreToolbarOnPause() {
this.isToolbarHidden =
fullscreenView.classList.contains('toolbar-hidden');
if (this.isToolbarHidden === true) {
fullscreenView.classList.remove('toolbar-hidden');
}
delete this.isToolbarHidden;
};
// Each of the Frame container elements may be subject to animated
// transitions. So give them transitionend event handlers that
// remove the transition style property when the transition ends.
// This helps prevent unexpected transitions.
function removeTransition(event) {
event.target.style.transition = null;
}
previousFrame.container.addEventListener('transitionend', removeTransition);
currentFrame.container.addEventListener('transitionend', removeTransition);
nextFrame.container.addEventListener('transitionend', removeTransition);
// Clicking the delete button while viewing a single item deletes that item
function deleteSingleItem() {
var msg;
if (files[currentFileIndex].metadata.video) {
msg = 'delete-video?';
}
else {
msg = 'delete-photo?';
}
// We need to disable NFC sharing when showing delete confirmation dialog
NFC.unshare();
Dialogs.confirm({
messageId: msg,
cancelId: 'cancel',
confirmId: 'delete',
danger: true,
bodyClass: 'showing-dialog'
}, function() { // onSuccess
// disable delete, edit and share button to prevent
// operations while delete item
fullscreenButtons.delete.classList.add('disabled');
fullscreenButtons.share.classList.add('disabled');
fullscreenButtons.edit.classList.add('disabled');
deleteFile(currentFileIndex);
// Enable NFC sharing when done deleting and returns to fullscreen view
NFC.share(getCurrentFile);
}, function() { // onCancel
// Enable NFC sharing when cancels delete and returns to fullscreen view
NFC.share(getCurrentFile);
});
}
// In fullscreen mode, the share button shares the current item
function shareSingleItem() {
// This is the item we're sharing
var fileinfo = files[currentFileIndex];
// If the item is a video, just share it
if (fileinfo.metadata.video) {
share([currentFrame.videoblob]);
}
else {
// Otherwise it is an image.
// If it does not have any EXIF orientation, and if we don't need
// to downsample it, then just share it as it is.
if (!fileinfo.metadata.rotation &&
!fileinfo.metadata.mirrored &&
!CONFIG_MAX_PICK_PIXEL_SIZE) {
share([currentFrame.imageblob]);
}
else {
// This is only tricky case. If we are sharing an image that uses
// EXIF orientation for correct display, rotate it before sharing
// so that the recieving app doesn't have to know about EXIF
LazyLoader.load(['shared/js/media/crop_resize_rotate.js'],
shareModifiedImage);
}
}
function shareModifiedImage() {
var metadata = fileinfo.metadata;
var button = fullscreenButtons.share;
button.classList.add('disabled');
Spinner.show();
var maxsize = CONFIG_MAX_PICK_PIXEL_SIZE || CONFIG_MAX_IMAGE_PIXEL_SIZE;
cropResizeRotate(currentFrame.imageblob, null,
maxsize || null, null, metadata,
function(error, rotatedBlob) {
Spinner.hide();
button.classList.remove('disabled');
if (error) {
console.error('Error while rotating image: ', error);
rotatedBlob = currentFrame.imageblob;
}
share([rotatedBlob], currentFrame.imageblob.name);
});
}
}
// In order to distinguish single taps from double taps, we have to
// wait after a tap arrives to make sure that a dbltap event isn't
// coming soon.
var taptimer = null;
function tapHandler(e) {
// Ignore tap event if 1. there is already a timer set, then this
// is the second tap and we're about to get a double tap event
// 2. currentFrame has not yet loaded any image or video.
if (taptimer ||
(!currentFrame.displayingImage && !currentFrame.displayingVideo)) {
return;
}
// If we don't get a second tap soon, then treat this as a single tap
taptimer = setTimeout(function() {
taptimer = null;
singletap(e);
}, GestureDetector.DOUBLE_TAP_TIME);
}
// Dispatch double tap events, but only when displaying a photo
function dblTapHandler(e) {
if (currentFrame.displayingVideo) {
return;
}
clearTimeout(taptimer);
taptimer = null;
doubletapOnPhoto(e);
}
// Resize all the frames' content, if its container's size is changed
function resizeFrames() {
nextFrame.reset();
previousFrame.reset();
currentFrame.reset();
}
// Handle single tap event on frames
// We manage the display of toolbar on the header and footer after user
// tap the image or video. If it's in preview mode, it simply switch to
// fullscreen mode directly.
function singletap(e) {
if (currentView === LAYOUT_MODE.fullscreen) {
if ((currentFrame.displayingImage || currentFrame.video.player.paused) &&
isPhone) {
fullscreenView.classList.toggle('toolbar-hidden');
}
} else if (!isPhone && currentView === LAYOUT_MODE.list &&
!files[currentFileIndex].metadata.video) {
// We don't separate cases by screen size, because we don't show
// preview screen on tiny device.
setView(LAYOUT_MODE.fullscreen);
resizeFrames();
}
}
// Quick zoom in and out with dbltap events
function doubletapOnPhoto(e) {
// Don't allow zooming while we're still scanning for photos and
// have found large photos without previews on the card. Zooming in
// decodes the full-size version of the photo and that can cause OOM
// errors if there is also metadata scanning going on with large images.
// XXX: Remove this when bug 854795 is fixed.
if (photodb.parsingBigFiles) {
return;
}
var scale;
if (currentFrame.fit.scale > currentFrame.fit.baseScale) { // If zoomed in
scale = currentFrame.fit.baseScale / currentFrame.fit.scale; // zoom out
} else { // Otherwise
scale = 2; // zoom in
}
currentFrame.zoom(scale, e.detail.clientX, e.detail.clientY, 200);
}
// Pan the item sideways when the user moves their finger across the screen
function panHandler(event) {
if (transitioning) {
return;
}
var dx = event.detail.relative.dx;
var dy = event.detail.relative.dy;
var oldFrameOffset = frameOffset;
// If the frames are already being shifted in the same direction as
// dx then this just continues the shift. Otherwise, dx might shift
// them back toward the center. If the frames are unshifted to begin
// with or become unshifted after applying dx, then we have got to
// pass dx to the pan() method of the frame, because it might pan
// the image within the frame. But that method returns any dx it
// can't use, and we apply that to shifting the frames.
// If the frames are already shifted and dx is in the same direction, or
// if dx is in the opposite direction but isn't big enough to bring
// the frames back to the center, just adjust the frame positions.
// There is no need to pan the content of the frame in this case.
if ((frameOffset > 0 && dx > 0) ||
(frameOffset < 0 && dx < 0) ||
(frameOffset !== 0 && frameOffset > -dx)) {
frameOffset += dx;
}
else {
// If the frame is shifted, this dx brings it back to center
if (frameOffset !== 0) {
dx += frameOffset;
frameOffset = 0;
}
// Now let the frame pan its content, and add any dx that it doesn't use
// to the frame offset
frameOffset += currentFrame.pan(dx, dy);
}
// Don't swipe past the end of the last item or past the start of the first
// Handle frameOffset reset in RTL when directions are reversed. See 1099458
if (document.documentElement.dir === 'ltr') {
if ((currentFileIndex === 0 && frameOffset > 0) ||
(currentFileIndex === files.length - 1 && frameOffset < 0)) {
frameOffset = 0;
}
} else {
if ((currentFileIndex === 0 && frameOffset < 0) ||
(currentFileIndex === files.length - 1 && frameOffset > 0)) {
frameOffset = 0;
}
}
// If the frameOffset has changed since we started, reposition the frames
if (frameOffset !== oldFrameOffset) {
setFramesPosition();
}
}
// When the user lifts their finger after panning we get this event
function swipeHandler(event) {
if (transitioning) {
return;
}
// If we just panned within a zoomed-in photo, and the frames are not
// shifted at all, then we don't have to do anything here.
if (frameOffset === 0) {
return;
}
// 1 means we're going to the next item -1 means the previous
var direction = (frameOffset < 0) ? 1 : -1;
// If we're in a right-to-left locale, reverse those directions
if (document.documentElement.dir === 'rtl') {
direction *= -1;
}
// Did we pan far enough or swipe fast enough to transition to
// a different item?
var farenough =
Math.abs(frameOffset) > window.innerWidth * TRANSITION_FRACTION;
var velocity = event.detail.vx;
var fastenough = Math.abs(velocity) > TRANSITION_SPEED;
// Make sure that that the speed and pan amount are in the same direction
var samedirection = velocity === 0 || frameOffset / velocity >= 0;
// Is there a next or previous item to transition to?
var fileexists =
(direction === 1 && currentFileIndex + 1 < files.length) ||
(direction === -1 && currentFileIndex > 0);
// If all of these conditions hold, then we'll transition to the
// next photo or the previous photo
var time;
if (direction !== 0 && (farenough || fastenough) &&
samedirection && fileexists) {
// Compute how long the transition should take based on the velocity
var speed = Math.max(Math.abs(velocity), TRANSITION_SPEED);
time = (window.innerWidth - Math.abs(frameOffset)) / speed;
// Transition frames in the appropriate direction
if (direction === 1) {
nextFile(time);
} else {
previousFile(time);
}
}
else if (frameOffset !== 0) {
// Otherwise, just restore the current item by undoing
// the translations we added during panning
time = Math.abs(frameOffset) / TRANSITION_SPEED;
currentFrame.container.style.transition =
nextFrame.container.style.transition =
previousFrame.container.style.transition =
'transform ' + time + 'ms ease';
resetFramesPosition();
// Ignore pan and zoom gestures while the transition happens
transitioning = true;
setTimeout(function() { transitioning = false; }, time);
}
}
// When a screen reader swipes with two fingers
function wheelHandler(event) {
if (event.deltaMode !== event.DOM_DELTA_PAGE || !event.deltaX) {
return;
}
if (event.deltaX > 0) {
nextFile(150);
} else {
previousFile(150);
}
}
// We also support pinch-to-zoom
function transformHandler(e) {
if (transitioning) {
return;
}
// Don't allow zooming while we're still scanning for photos and
// have found large photos without previews on the card. Zooming in
// decodes the full-size version of the photo and that can cause OOM
// errors if there is also metadata scanning going on with large images.
// XXX: Remove this when bug 854795 is fixed.
if (photodb.parsingBigFiles) {
return;
}
currentFrame.zoom(e.detail.relative.scale,
e.detail.midpoint.clientX,
e.detail.midpoint.clientY);
}
// A utility function to display the nth image or video in the specified frame
// Used in showFile(), nextFile() and previousFile().
function setupFrameContent(n, frame) {
// Make sure n is in range
if (n < 0 || n >= files.length) {
frame.clear();
delete frame.filename;
return;
}
var fileinfo = files[n];
// If we're already displaying this file in this frame, then do nothing
if (fileinfo.name === frame.filename) {
return;
}
// Remember what file we're going to display
frame.filename = fileinfo.name;
photodb.getFile(fileinfo.name, function(imagefile) {
if (fileinfo.metadata.video) {
// If this is a video, then the file we just got is the poster image
// and we still have to fetch the actual video
getVideoFile(fileinfo.metadata.video, function(videofile) {
frame.displayVideo(videofile, imagefile,
fileinfo.metadata.width / fileinfo.metadata.height,
fileinfo.metadata.rotation || 0);
});
}
else {
// Otherwise, just display the image
frame.displayImage(
imagefile,
fileinfo.metadata.width,
fileinfo.metadata.height,
fileinfo.metadata.preview,
fileinfo.metadata.rotation,
fileinfo.metadata.mirrored);
}
});
}
var FRAME_BORDER_WIDTH = 3;
function setFramesPosition() {
var width = window.innerWidth + FRAME_BORDER_WIDTH;
currentFrame.container.style.transform =
'translateX(' + frameOffset + 'px)';
if (document.documentElement.dir === 'ltr') {
nextFrame.container.style.transform =
'translateX(' + (frameOffset + width) + 'px)';
previousFrame.container.style.transform =
'translateX(' + (frameOffset - width) + 'px)';
}
else {
// For RTL languages we swap next and previous sides
nextFrame.container.style.transform =
'translateX(' + (frameOffset - width) + 'px)';
previousFrame.container.style.transform =
'translateX(' + (frameOffset + width) + 'px)';
}
// XXX Bug 1021782 add 'current' class to currentFrame
nextFrame.container.classList.remove('current');
previousFrame.container.classList.remove('current');
currentFrame.container.classList.add('current');
// Hide adjacent frames from screen reader
nextFrame.container.setAttribute('aria-hidden', true);
previousFrame.container.setAttribute('aria-hidden', true);
currentFrame.container.removeAttribute('aria-hidden');
}
function resetFramesPosition() {
frameOffset = 0;
setFramesPosition();
}
// Switch from thumbnail list view to single-picture fullscreen view
// and display the specified file.
function showFile(n) {
// Mark what we're focusing on and unmark the old one
updateFocusThumbnail(n);
updateFrames();
// Disable the edit button if this is a video or mediaDB is scanning
if (files[currentFileIndex].metadata.video ||
photodb.scanning) {
fullscreenButtons.edit.classList.add('disabled');
} else {
fullscreenButtons.edit.classList.remove('disabled');
}
// Always bring delete and share button back after show file
fullscreenButtons.delete.classList.remove('disabled');
fullscreenButtons.share.classList.remove('disabled');
}
function updateFrames() {
var n = currentFileIndex;
setupFrameContent(n - 1, previousFrame);
setupFrameContent(n, currentFrame);
setupFrameContent(n + 1, nextFrame);
resetFramesPosition();
}
function clearFrames() {
previousFrame.clear();
currentFrame.clear();
nextFrame.clear();
delete previousFrame.filename;
delete currentFrame.filename;
delete nextFrame.filename;
}
// Transition to the next file, animating it over the specified time (ms).
// This is used when the user pans.
function nextFile(time) {
// If already displaying the last one, do nothing.
if (currentFileIndex === files.length - 1) {
return;
}
// If the current frame is using a <video> element instead of just
// displaying a poster image, reset it back to just the image
if (currentFrame.displayingVideo && currentFrame.video.playerShowing) {
currentFrame.video.init();
}
// Set a flag to ignore pan and zoom gestures during the transition.
transitioning = true;
// Set transitions for the visible frames
var transition = 'transform ' + time + 'ms ease';
currentFrame.container.style.transition = transition;
nextFrame.container.style.transition = transition;
// Cycle the three frames so next becomes current,
// current becomes previous, and previous becomes next.
var tmp = previousFrame;
previousFrame = currentFrame;
currentFrame = nextFrame;
nextFrame = tmp;
updateFocusThumbnail(currentFileIndex + 1);
// Move (transition) the frames to their new position
resetFramesPosition();
// Update the frame for the new next item
setupFrameContent(currentFileIndex + 1, nextFrame);
// When the transition is done, cleanup
currentFrame.container.addEventListener('transitionend', function done(e) {
this.removeEventListener('transitionend', done);
transitioning = false;
// Reposition the item that just transitioned off the screen
// to reset any zooming and panning
previousFrame.reset();
});
// Disable the edit button if this is a video or
// mediaDB is scanning, enable otherwise
if (currentFrame.displayingVideo || photodb.scanning) {
fullscreenButtons.edit.classList.add('disabled');
} else {
fullscreenButtons.edit.classList.remove('disabled');
}
}
// Just like nextFile() but in the other direction
function previousFile(time) {
// if already displaying the first one, do nothing.
if (currentFileIndex === 0) {
return;
}
// If the current frame is using a <video> element instead of just
// displaying a poster image, reset it back to just the image.
if (currentFrame.displayingVideo && currentFrame.video.playerShowing) {
currentFrame.video.init();
}
// Set a flag to ignore pan and zoom gestures during the transition.
transitioning = true;
// Set transitions for the visible frames
var transition = 'transform ' + time + 'ms ease';
previousFrame.container.style.transition = transition;
currentFrame.container.style.transition = transition;
// Transition to the previous item: previous becomes current, current
// becomes next, etc.
var tmp = nextFrame;
nextFrame = currentFrame;
currentFrame = previousFrame;
previousFrame = tmp;
updateFocusThumbnail(currentFileIndex - 1);
// Move (transition) the frames to their new position
resetFramesPosition();
// Preload the new previous item
setupFrameContent(currentFileIndex - 1, previousFrame);
// When the transition is done do some cleanup
currentFrame.container.addEventListener('transitionend', function done(e) {
this.removeEventListener('transitionend', done);
transitioning = false;
// Reset the size and position of the item that just panned off
nextFrame.reset();
});
// Disable the edit button if we're now viewing a video or mediaDB
// is scanning, enable otherwise
if (currentFrame.displayingVideo || photodb.scanning) {
fullscreenButtons.edit.classList.add('disabled');
} else {
fullscreenButtons.edit.classList.remove('disabled');
}
}