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.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

830
gaia/apps/gallery/js/gallery.js Executable file
View file

@ -0,0 +1,830 @@
'use strict';
/* global
$,
clearFrames,
currentFrame,
Dialogs,
files,
LazyLoader,
Pick,
picking,
MediaDB,
MozActivity,
NFC,
Overlay,
photodb,
resizeFrames,
ScreenLayout,
setFramesPosition,
showFile
*/
/* exported
compareFilesByDate,
deleteSelectedItems,
doNotScanInBackgroundHack,
fileCreated,
fileDeleted,
fullscreenView,
launchCameraApp,
resizeHandler,
shareSelectedItems,
thumbnailClickHandler,
TRANSITION_FRACTION,
TRANSITION_SPEED
*/
/*
* This app displays photos and videos that are stored on the phone.
*
* Its starts with a thumbnail view in which small versions of all photos
* and videos are displayed. Tapping on a thumbnail shows the image
* or video at the full size of the screen and swiping left or right moves to
* the next or previous image or video.
*
* The app supports two-finger "pinch" gestures to zoom in and out on an
* image. When zoomed, a one finger swipe gesture pans within the zoomed
* image, and only moves to the next or previous image once you reach the
* edge of the currently displayed image.
*
* To make transitions between photos smooth, the app preloads the next
* and previous image or video and positions them off-screen to the right and
* left of the currently displayed image.
*
* Image and videos are displayed in "frames" which are managed by
* the Frame.js abstraction. A Frame object includes a video player UI
* (from VideoPlayer.js) and also includes the code that manage zooming
* and panning within an image.
*/
//
// Tuneable parameters
//
// Pan this % of width to transition from one item to the next
var TRANSITION_FRACTION = 0.25;
// This is the speed of our default transitions in pixels/ms.
// Swipe faster than this to transition faster. But we'll
// never go slower (except slide show transitions).
var TRANSITION_SPEED = 0.75;
// UI elements
var thumbnails;
var thumbnailList;
var fullscreenView = $('fullscreen-view');
// These are the top-level view class which are defined in
// gallery_tablet.css
// This object is used by setView()
// Layout Mode Transition:
// list <-> selection
// list <-> fullscreen <-> edit/crop
// (activity) pick <-> crop
const LAYOUT_MODE = {
list: 'thumbnailListView',
select: 'thumbnailSelectView',
fullscreen: 'fullscreenView',
edit: 'editView',
pick: 'pickView',
crop: 'cropView'
};
var currentView;
// Register orientation watcher in ScreenLayout
ScreenLayout.watch('portrait', '(orientation: portrait)');
var isPortrait = ScreenLayout.getCurrentLayout('portrait');
var isPhone = ScreenLayout.getCurrentLayout('tiny');
var fullscreenButtonIds = ['back', 'delete', 'edit', 'share', 'camera', 'info'];
var fullscreenButtons = {};
for (var i = 0; i < fullscreenButtonIds.length; i++) {
var selector = 'fullscreen-' + fullscreenButtonIds[i] + '-button';
selector += (isPhone ? '-tiny' : '-large');
fullscreenButtons[fullscreenButtonIds[i]] = document.getElementById(selector);
}
var currentFileIndex = 0; // What file is currently displayed
var editedPhotoIndex;
// In thumbnailSelectView, we allow the user to select thumbnails.
// These variables hold the names of the selected files, and map those
// names to the corresponding File objects
var selectedFileNames = [];
var selectedFileNamesToBlobs = {};
// We manage videos through their poster images, which are photos and so get
// listed in the photodb above. But when we need to access the actual video
// file, we have to get that from a device storage object for videos.
var videostorage = navigator.getDeviceStorage('videos');
// Flag that indicates that we've edited a picture and just saved it
var justSavedEditedImage = false;
// Pass the filename of the poster image and get the video file back
function getVideoFile(filename, callback) {
// We get videos directly through the video device storage
var req = videostorage.get(filename);
req.onsuccess = function() {
callback(req.result);
};
req.onerror = function() {
console.error('Failed to get video file', filename);
};
}
// Return a Promise that resolves to the File object of the currently
// displayed image or video.
// XXX This function is temporarily added here as part of NFC
// refactoring, but will be moved into the model module.
function getCurrentFile() {
return new Promise(function(resolve, reject) {
var fileInfo = files[currentFileIndex];
if (fileInfo.metadata.video) {
getVideoFile(fileInfo.metadata.video, function(file) {
resolve(file);
});
} else {
// share photo
photodb.getFile(fileInfo.name, function(file) {
resolve(file);
}, function(errmsg) {
reject(errmsg);
});
}
});
}
// This comparison function is used for sorting arrays and doing binary
// search on the resulting sorted arrays.
function compareFilesByDate(a, b) {
if (a.date < b.date) {
return 1; // larger (newer) dates come first
} else if (a.date > b.date) {
return -1;
}
return 0;
}
// getFileIndex return position of a file thumbnail in gallery view
// It first find thumbnail's position within its own group
// then add the sizes of previous groups
function getFileIndex(filename) {
// Get the group of the file
var fileGroup = thumbnailList.groupMap[filename];
if (!fileGroup) {
console.error('file group does not exist in thumbnail List', filename);
return -1;
}
var index = 0;
// Get the thumbnailItem of the file
var thumbnail = thumbnailList.thumbnailMap[filename];
// Get the index of the file in its own group
index = fileGroup.thumbnails.indexOf(thumbnail);
if (index < 0) {
console.error('filename does not exist in thumbnail list', filename);
return -1;
}
// Add to index the sizes of previous groups
for (var n = 0; n < thumbnailList.itemGroups.length; n++) {
if (thumbnailList.itemGroups[n].groupID === fileGroup.groupID) {
break;
}
index += thumbnailList.itemGroups[n].getCount();
}
return index;
}
function fileDeleted(filename) {
var fileIndex = currentFileIndex;
// Find the deleted file in our files array
for (var n = 0; n < files.length; n++) {
if (files[n].name === filename) {
break;
}
}
if (n >= files.length) { // It was a file we didn't know about
return;
}
// Remove the image from the array
files.splice(n, 1)[0];
// Remove the corresponding thumbnail
thumbnailList.removeItem(filename);
// Adjust currentFileIndex, too, if we have to.
if (n < fileIndex) {
fileIndex--;
}
// If we remove the last item in files[],
// we need to show the previous image, not the next image.
if (fileIndex >= files.length) {
fileIndex = files.length - 1;
}
if (n < editedPhotoIndex) {
editedPhotoIndex--;
}
// If we're in fullscreen mode or has preview screen, then the only way
// this function gets called is when we delete the currently displayed photo.
// This means that we need to redisplay.
if (files.length > 0 && (currentView === LAYOUT_MODE.fullscreen)) {
showFile(fileIndex);
} else {
updateFocusThumbnail(fileIndex);
}
// If there are no more photos show the "no pix" overlay
if (files.length === 0) {
if (currentView !== LAYOUT_MODE.pick) {
setView(LAYOUT_MODE.list);
}
Overlay.show('emptygallery');
}
}
function deleteFile(n) {
if (n < 0 || n >= files.length) {
return;
}
// Delete the file from the MediaDB. This removes the db entry and
// deletes the file in device storage. This will generate an change
// event which will call imageDeleted()
var fileinfo = files[n];
photodb.deleteFile(files[n].name);
// If it is a video, however, we can't just delete the poster image, but
// must also delete the video file.
if (fileinfo.metadata.video) {
videostorage.delete(fileinfo.metadata.video);
}
// If the metdata parser saved a preview image for this photo,
// delete that, too.
if (fileinfo.metadata.preview && fileinfo.metadata.preview.filename) {
// We use raw device storage here instead of MediaDB because that is
// what MetadataParser.js uses for saving the preview.
var pictures = navigator.getDeviceStorage('pictures');
pictures.delete(fileinfo.metadata.preview.filename);
}
}
function fileCreated(fileinfo) {
// If the new file is a video and we're handling an image pick activity
// then we won't display the new file.
if (picking && fileinfo.metadata.video) {
return;
}
// The fileinfo object that MediaDB sends us has a thumbnail blob in it,
// fresh from the metadata parser. This blob has been stored in the db, but
// the copy we have is still a memory-backed blob. We need to be using a
// file backed blob here so that we don't leak memory if the user scans a
// fresh sdcard full of hundreds of photos. So we go get the db record out
// of the database. It should be an exact copy of what we already have,
// except that the thumbnail will be file-backed instead of memory-backed.
photodb.getFileInfo(fileinfo.name, function(fileinfo) {
var insertPosition;
// If we were showing the 'no pictures' overlay, hide it
if (Overlay.current === 'emptygallery' || Overlay.current === 'scanning') {
Overlay.hide();
}
// Create a thumbnailItem for this image and insert it at the right spot
thumbnailList.addItem(fileinfo);
insertPosition = getFileIndex(fileinfo.name);
if (insertPosition < 0) {
return;
}
// Insert the image info into the array
files.splice(insertPosition, 0, fileinfo);
if (currentFileIndex >= insertPosition) {
currentFileIndex++;
}
if (editedPhotoIndex >= insertPosition) {
editedPhotoIndex++;
}
// Redisplay the current photo if we're in photo view. The current
// photo should not change, but the content of the next or previous frame
// might. This call will only make changes if the filename to display
// in a frame has actually changed.
if (currentView === LAYOUT_MODE.fullscreen) {
if (justSavedEditedImage) {
var banner = $('edit-copy-save-banner');
// Show latest saved file inserted at index insertPosition
showFile(insertPosition);
document.l10n.setAttributes($('edit-copy-save-status'),
'edit-copy-saved');
banner.hidden = false;
setTimeout(function() { banner.hidden = true; }, 3000);
} else {
showFile(currentFileIndex);
}
}
justSavedEditedImage = false;
});
}
// Make the thumbnail for image n visible
function scrollToShowThumbnail(n) {
if (!files[n]) {
return;
}
var selector = 'img[data-filename="' + files[n].name + '"]';
var thumbnail = thumbnails.querySelector(selector);
if (thumbnail) {
var screenTop = thumbnails.scrollTop;
var screenBottom = screenTop + thumbnails.clientHeight;
var thumbnailTop = thumbnail.offsetTop;
var thumbnailBottom = thumbnailTop + thumbnail.offsetHeight;
var toolbarHeight = 40; // compute this dynamically?
// Adjust the screen bottom up to be above the overlaid footer
screenBottom -= toolbarHeight;
if (thumbnailTop < screenTop) { // If thumbnail is above screen
thumbnails.scrollTop = thumbnailTop; // scroll up to show it.
}
else if (thumbnailBottom > screenBottom) { // If thumbnail is below screen
thumbnails.scrollTop = // scroll down to show it
thumbnailBottom - thumbnails.clientHeight + toolbarHeight;
}
}
}
function setView(view) {
if (currentView === view) {
return;
}
// define each view's layout based on data-view of body
document.body.classList.remove(currentView);
document.body.classList.add(view);
// Do any necessary cleanup of the view we're exiting
switch (currentView) {
case LAYOUT_MODE.select:
// Clear the selection, if there is one
Array.forEach(thumbnails.querySelectorAll('.selected.thumbnailImage'),
function(elt) { elt.classList.remove('selected'); });
// On large devices we need to display the new current file after deletion
// But if we just deleted the last file then we don't do this
if (!isPhone && currentFileIndex !== -1) {
showFile(currentFileIndex);
}
break;
case LAYOUT_MODE.fullscreen:
if (!isPhone && (view === LAYOUT_MODE.list) && !isPortrait &&
currentFileIndex !== -1) {
// we'll reuse and resize the fullscreen window
// when go back to thumbnailList mode from fullscreen
// and also does editView in landscape
resizeFrames();
} else {
// Clear the frames to release the memory they're holding and
// so that we don't see a flash of the old image when we return
// to fullscreen view
clearFrames();
}
break;
}
switch (view) {
case LAYOUT_MODE.list:
// If we're going to fullscreen, then we were just viewing a photo
// or video, so make sure its thumbnail is fully on the screen.
// XXX: do we need to defer this?
scrollToShowThumbnail(currentFileIndex);
if (currentView === LAYOUT_MODE.fullscreen) {
// only do it when we back from fullscreen.
NFC.unshare();
}
break;
case LAYOUT_MODE.fullscreen:
resizeFrames();
NFC.share(getCurrentFile);
break;
case LAYOUT_MODE.select:
clearSelection();
// When entering select view, we pause the video
if (!isPhone && currentFrame.video && !isPortrait) {
currentFrame.video.pause();
}
break;
case LAYOUT_MODE.edit:
NFC.unshare();
break;
}
// We reuse the fullscreen dom for preview and fullscreen dom,
// so the title must be changed while switching
if (!isPhone) {
if (view !== LAYOUT_MODE.fullscreen) {
$('fullscreen-title').setAttribute('data-l10n-id', 'preview');
} else {
$('fullscreen-title').setAttribute('data-l10n-id', 'gallery');
}
}
// Remember the current view
currentView = view;
}
//
// Event handlers
//
// Clicking on a thumbnail does different things depending on the view.
// 1. On pickView -> go to cropMode
// 2. On large/selectView -> update preview image
// 3. On tiny/large with listView -> go to fullscreen image
function thumbnailClickHandler(evt) {
var target = evt.target;
if (!target) {
return;
}
// Bug 1106877 - Handle tap for clicks in gray area of containing
// div for thumbnail images smaller than thumbnail container.
target = target.classList.contains('thumbnail') ?
target.firstElementChild : target;
if (!target || !target.classList.contains('thumbnailImage')) {
return;
}
// If the MediaDB is not fully ready yet, then ignore the event
if (photodb.state !== MediaDB.READY) {
return;
}
var index = getFileIndex(target.dataset.filename);
if (picking && currentView === LAYOUT_MODE.pick && index >= 0) {
Pick.select(files[index]);
} else if (currentView === LAYOUT_MODE.select) {
updateSelection(target);
} else {
LazyLoader.load('js/frame_scripts.js', function() {
if (isPortrait || isPhone) {
setView(LAYOUT_MODE.fullscreen);
}
showFile(index);
});
}
}
// On large screen, we outline the picture we're focusing on and
// update the currentFileIndex.
function updateFocusThumbnail(n) {
var previousIndex = currentFileIndex;
currentFileIndex = n;
if (isPhone || currentFileIndex === -1) {
return;
}
// If file is delted on select mode, the currentFileIndex may
// be the same as previousIndex. We need to hightlight it again.
var newTarget =
thumbnailList.thumbnailMap[files[currentFileIndex].name];
if (newTarget) {
newTarget.htmlNode.classList.add('focus');
}
if (previousIndex === currentFileIndex) {
return;
}
var oldTarget =
files[previousIndex] ?
thumbnailList.thumbnailMap[files[previousIndex].name] :
undefined;
if (oldTarget) {
oldTarget.htmlNode.classList.remove('focus');
}
}
function clearSelection() {
if (!isPhone) {
// Clear preview screen on large device.
clearFrames();
}
selectedFileNames = [];
selectedFileNamesToBlobs = {};
$('thumbnails-delete-button').classList.add('disabled');
$('thumbnails-share-button').classList.add('disabled');
document.l10n.setAttributes(
$('thumbnails-number-selected'),
'number-selected2',
{ n: 0 }
);
}
// When we enter thumbnail selection mode, or when the selection changes
// we call this function to update the message the top of the screen and to
// enable or disable the Delete and Share buttons
function updateSelection(thumbnail) {
// First, update the visual appearance of the element
thumbnail.classList.toggle('selected');
// Now update the list of selected filenames and filename->blob map
// based on whether we selected or deselected the thumbnail
var selected = thumbnail.classList.contains('selected');
var index = getFileIndex(thumbnail.dataset.filename);
if (index < 0) {
return;
}
var filename = files[index].name;
if (selected) {
selectedFileNames.push(filename);
// currentFileIndex point to the last selected fileIndex
updateFocusThumbnail(index);
if (files[index].metadata.video) {
getVideoFile(files[index].metadata.video, function(file) {
selectedFileNamesToBlobs[filename] = file;
});
}
else {
// We get photos through the photo db
photodb.getFile(filename, function(file) {
selectedFileNamesToBlobs[filename] = file;
});
}
if (!isPhone) {
showFile(currentFileIndex);
}
}
else {
delete selectedFileNamesToBlobs[filename];
var i = selectedFileNames.indexOf(filename);
if (i !== -1) {
selectedFileNames.splice(i, 1);
}
if (currentFileIndex === index && !isPhone) {
if (i > 0) {
// show the last selected image of selectedFileNames.
var lastSelected = selectedFileNames[i - 1];
var lastSelectedIndex = getFileIndex(lastSelected);
updateFocusThumbnail(lastSelectedIndex);
showFile(currentFileIndex);
} else {
// If selectedFileNames is empty, we clear preview
// screen.
clearFrames();
}
}
}
// Now update the UI based on the number of selected thumbnails
var numSelected = selectedFileNames.length;
document.l10n.setAttributes(
$('thumbnails-number-selected'),
'number-selected2',
{ n: numSelected }
);
if (numSelected === 0) {
$('thumbnails-delete-button').classList.add('disabled');
$('thumbnails-share-button').classList.add('disabled');
}
else {
$('thumbnails-delete-button').classList.remove('disabled');
$('thumbnails-share-button').classList.remove('disabled');
}
}
function launchCameraApp() {
fullscreenButtons.camera.classList.add('disabled');
$('thumbnails-camera-button').classList.add('disabled');
$('overlay-camera-button').classList.add('disabled');
var a = new MozActivity({
name: 'record',
data: {
type: 'photos'
}
});
// Assign an onsuccess function to the activity so jshint doesn't complain.
a.onsuccess = () => {};
// Wait 2000ms before re-enabling the Camera buttons to prevent
// hammering them and causing a crash (Bug 957709)
window.setTimeout(function() {
fullscreenButtons.camera.classList.remove('disabled');
$('thumbnails-camera-button').classList.remove('disabled');
$('overlay-camera-button').classList.remove('disabled');
}, 2000);
}
function deleteSelectedItems() {
var selected = thumbnails.querySelectorAll('.selected.thumbnailImage');
if (selected.length === 0) {
return;
}
Dialogs.confirm({
messageId: 'delete-n-items?',
messageArgs: {n: selected.length},
cancelId: 'cancel',
confirmId: 'delete',
danger: true,
bodyClass: 'showing-dialog'
}, function() { // onSuccess
// deleteFile is O(n), so this loop is O(n*n). If used with really large
// selections, it might have noticably bad performance. If so, we
// can write a more efficient deleteFiles() function.
for (var i = 0; i < selected.length; i++) {
selected[i].classList.toggle('selected');
deleteFile(getFileIndex(selected[i].dataset.filename));
}
clearSelection();
});
}
// Clicking on the share button in select mode shares all selected images
// Note: when sharing multiple items, we make no attempt to handle EXIF
// orientation in photos. So on devices (like Tarako) that rely on EXIF
// orientation in the camera app, we may be leaking images that will not
// work well on the web.
function shareSelectedItems() {
var blobs = selectedFileNames.map(function(name) {
return selectedFileNamesToBlobs[name];
});
share(blobs);
}
// Initiate a share activity for all of the Blobs in the blobs array.
// Usually these blobs are File objects, with names. But when sharing a
// single image, we sometimes pass an in-memory blob to handle EXIF orientation
// issues. In that case, use the second name argument for the unnamed blob
function share(blobs, blobName) {
if (blobs.length === 0) {
return;
}
var names = [], types = [], fullpaths = [];
// Get the file name (minus path) and type of each blob
blobs.forEach(function(blob) {
var name = blob.name;
// Special case for blobs that are not File objects
if (!name && blobs.length === 1) {
name = blobName;
}
// We try to fix Bug 814323 by using
// current workaround of bluetooth transfer
// so we will pass both filenames and fullpaths
// The fullpaths can be removed after Bug 811615 is fixed
fullpaths.push(name);
// Discard the path, we just want the base name
name = name.substring(name.lastIndexOf('/') + 1);
names.push(name);
// And we just want the first component of the type "image" or "video"
var type = blob.type;
if (type) {
type = type.substring(0, type.indexOf('/'));
}
types.push(type);
});
// If there is just one type, or if all types are the same, then use
// that type plus '/*'. Otherwise, use 'application/*'
// If all the blobs are image we use 'image/*'. If all are videos
// we use 'video/*'. Otherwise, 'application/*'.
var type;
if (types.length === 1 ||
types.every(function(t) { return t === types[0]; })) {
type = types[0] + '/*';
} else {
type = 'application/*';
}
var a = new MozActivity({
name: 'share',
data: {
type: type,
number: blobs.length,
blobs: blobs,
filenames: names,
filepaths: fullpaths
}
});
a.onerror = function(e) {
if (a.error.name === 'NO_PROVIDER') {
document.l10n.formatValue('share-noprovider').then(alert);
}
else {
console.warn('share activity error:', a.error.name);
}
};
}
// This happens when the user rotates the phone.
// When we used mozRequestFullscreen, it would also happen
// when we entered or left fullscreen mode.
// As a workaround for Bug 961636, use resize event handler
// in place of screenlayoutchange event handler.
function resizeHandler() {
isPortrait = ScreenLayout.getCurrentLayout('portrait');
// In list view when video is playing, if user rotate screen from
// landscape to portrait, the video pause.
// Check if currentFrame is undefined for cases where frame_script.js
// is not loaded e.g. if a user rotates phone
// in list mode before opening an image.
if (currentView === LAYOUT_MODE.list && isPortrait &&
typeof currentFrame !== 'undefined' && currentFrame.video) {
currentFrame.video.pause();
}
// We'll need to resize and reposition frames for below cases, since
// the size of container has been changed.
if (currentView === LAYOUT_MODE.fullscreen ||
(!isPhone && !isPortrait &&
(currentView === LAYOUT_MODE.list ||
currentView === LAYOUT_MODE.select))) {
resizeFrames();
// We also have to reposition the frames to get the next and previous
// frames the correct distance away from the current frame
setFramesPosition();
}
}
/*
* This is a temporary workaround to bug 1039943: when the user launches
* the gallery and then switches to another app gallery's scanning and
* thumbnail generation process can slow down foreground apps.
*
* For now, we address this by simply making the app exit if it goes to the
* background while scanning and if the device has 256mb or less of memory.
*
* Bug 1046995 should fix this issue in a better way and when it does,
* we should remove this function and the code that invokes it.
*/
function doNotScanInBackgroundHack(photodb) {
const enoughMB = 512; // How much memory is enough to not do this hack?
var memoryMB = 0; // How much memory do we have?
// Listen for visibilitychange events that happen when we go to the background
window.addEventListener('visibilitychange', backgroundScanKiller);
// Stop listening for those events when the scan is complete
photodb.addEventListener('scanend', function() {
window.removeEventListener('visibilitychange', backgroundScanKiller);
});
// This is what we do when we go to the background
function backgroundScanKiller() {
// If we're coming back to the foreground or if we already know that
// we have enough memory, then do nothing here.
if (!document.hidden || memoryMB >= enoughMB) {
return;
}
// If we can't query our memory (i.e. this is the 1.4 Dolphin release)
// then assume that we have low memory and exit
if (!navigator.getFeature) {
exit();
}
else {
// Otherwise we're in release 2.0 or later and can actually query
// how much memory we have.
navigator.getFeature('hardware.memory').then(function(mem) {
memoryMB = mem;
if (memoryMB < enoughMB) {
exit();
}
});
}
function exit() {
// If we are still hidden and still scanning, then log a message
// wait a bit for the log to be flushed, and then close the application
if (document.hidden && photodb.scanning) {
console.warn('[Gallery] exiting to avoid background scan.');
setTimeout(function() {
if (document.hidden) {
window.close();
}
else {
console.warn('[Gallery] now visible again, so not exiting');
}
}, 500);
}
}
}
}