blob: 76b9e408b9468a23eac71effa11187b532dcb048 [file] [log] [blame]
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* A thumbnail loader for list style UI.
*
* ListThumbnailLoader is a thubmanil loader designed for list style ui. List
* thumbnail loader loads thumbnail in a viewport of the UI. ListThumbnailLoader
* is responsible to return dataUrls of thumbnails and fetch them with proper
* priority.
*/
class ListThumbnailLoader extends cr.EventTarget {
/**
* @param {!DirectoryModel} directoryModel A directory model.
* @param {!ThumbnailModel} thumbnailModel Thumbnail metadata model.
* @param {!VolumeManager} volumeManager Volume manager.
* @param {Function=} opt_thumbnailLoaderConstructor A constructor of
* thumbnail loader. This argument is used for testing.
*/
constructor(
directoryModel, thumbnailModel, volumeManager,
opt_thumbnailLoaderConstructor) {
super();
/**
* @private {!DirectoryModel}
*/
this.directoryModel_ = directoryModel;
/**
* @private {!ThumbnailModel}
*/
this.thumbnailModel_ = thumbnailModel;
/**
* @private {!VolumeManager}
*/
this.volumeManager_ = volumeManager;
/**
* Constructor of thumbnail loader.
* @private {!Function}
*/
this.thumbnailLoaderConstructor_ =
opt_thumbnailLoaderConstructor || ThumbnailLoader;
/**
* @private {!Object<!ListThumbnailLoader.Task>}
*/
this.active_ = {};
/**
* @private {LRUCache<!ListThumbnailLoader.ThumbnailData>}
*/
this.cache_ = new LRUCache(ListThumbnailLoader.CACHE_SIZE);
/**
* @private {number}
*/
this.beginIndex_ = 0;
/**
* @private {number}
*/
this.endIndex_ = 0;
/**
* Cursor.
* @private {number}
*/
this.cursor_ = 0;
/**
* Current volume type.
* @private {?ListThumbnailLoader.VolumeType}
*/
this.currentVolumeType_ = null;
/**
* @private {!FileListModel}
*/
this.dataModel_ = assert(this.directoryModel_.getFileList());
this.directoryModel_.addEventListener(
'scan-completed', this.onScanCompleted_.bind(this));
this.dataModel_.addEventListener('splice', this.onSplice_.bind(this));
this.dataModel_.addEventListener('sorted', this.onSorted_.bind(this));
this.dataModel_.addEventListener('change', this.onChange_.bind(this));
}
/**
* Gets number of prefetch requests. This number changes based on current
* volume type.
* @return {number} Number of prefetch requests.
* @private
*/
getNumOfPrefetch_() {
switch (/** @type {?ListThumbnailLoader.VolumeType} */
(this.currentVolumeType_)) {
case VolumeManagerCommon.VolumeType.MTP:
return 0;
case ListThumbnailLoader.TEST_VOLUME_TYPE:
return 1;
default:
return 20;
}
}
/**
* Gets maximum number of active thumbnail fetch tasks. This number changes
* based on current volume type.
* @return {number} Maximum number of active thumbnail fetch tasks.
* @private
*/
getNumOfMaxActiveTasks_() {
switch (/** @type {?ListThumbnailLoader.VolumeType} */
(this.currentVolumeType_)) {
case VolumeManagerCommon.VolumeType.MTP:
return 1;
case ListThumbnailLoader.TEST_VOLUME_TYPE:
return ListThumbnailLoader.numOfMaxActiveTasksForTest;
default:
return 10;
}
}
/**
* An event handler for scan-completed event of directory model. When
* directory scan is running, we don't fetch thumbnail in order not to block
* IO for directory scan. i.e. modification events during directory scan is
* ignored. We need to check thumbnail loadings after directory scan is
* completed.
*
* @param {!Event} event Event
*/
onScanCompleted_(event) {
this.cursor_ = this.beginIndex_;
this.continue_();
}
/**
* An event handler for splice event of data model. When list is changed,
* start to rescan items.
*
* @param {!Event} event Event
*/
onSplice_(event) {
this.cursor_ = this.beginIndex_;
this.continue_();
}
/**
* An event handler for sorted event of data model. When list is sorted, start
* to rescan items.
*
* @param {!Event} event Event
*/
onSorted_(event) {
this.cursor_ = this.beginIndex_;
this.continue_();
}
/**
* An event handler for change event of data model.
*
* @param {!Event} event Event
*/
onChange_(event) {
// Mark the thumbnail in cache as invalid.
const entry = this.dataModel_.item(event.index);
const cachedThumbnail = this.cache_.peek(entry.toURL());
if (cachedThumbnail) {
cachedThumbnail.outdated = true;
}
this.cursor_ = this.beginIndex_;
this.continue_();
}
/**
* Sets high priority range in the list.
*
* @param {number} beginIndex Begin index of the range, inclusive.
* @param {number} endIndex End index of the range, exclusive.
*/
setHighPriorityRange(beginIndex, endIndex) {
if (!(beginIndex < endIndex)) {
return;
}
this.beginIndex_ = beginIndex;
this.endIndex_ = endIndex;
this.cursor_ = this.beginIndex_;
this.continue_();
}
/**
* Returns a thumbnail of an entry if it is in cache. This method returns
* thumbnail even if the thumbnail is outdated.
*
* @return {ListThumbnailLoader.ThumbnailData} If the thumbnail is not in
* cache, this returns null.
*/
getThumbnailFromCache(entry) {
// Since we want to evict cache based on high priority range, we use peek
// here instead of get.
return this.cache_.peek(entry.toURL()) || null;
}
/**
* Enqueues tasks if available.
*/
continue_() {
// If directory scan is running or all items are scanned, do nothing.
if (this.directoryModel_.isScanning() ||
!(this.cursor_ < this.dataModel_.length)) {
return;
}
const entry = /** @type {Entry} */ (this.dataModel_.item(this.cursor_));
// Check volume type for optimizing the parameters.
const volumeInfo = this.volumeManager_.getVolumeInfo(assert(entry));
this.currentVolumeType_ = volumeInfo ? volumeInfo.volumeType : null;
// If tasks are running full or all items are scanned, do nothing.
if (!(Object.keys(this.active_).length < this.getNumOfMaxActiveTasks_()) ||
!(this.cursor_ < this.endIndex_ + this.getNumOfPrefetch_())) {
return;
}
// If the entry is a directory, already in cache as valid or fetching, skip.
const thumbnail = this.cache_.get(entry.toURL());
if (entry.isDirectory || (thumbnail && !thumbnail.outdated) ||
this.active_[entry.toURL()]) {
this.cursor_++;
this.continue_();
return;
}
this.enqueue_(this.cursor_, entry);
this.cursor_++;
this.continue_();
}
/**
* Enqueues a thumbnail fetch task for an entry.
*
* @param {number} index Index of an entry in current data model.
* @param {!Entry} entry An entry.
*/
enqueue_(index, entry) {
const task = new ListThumbnailLoader.Task(
entry, this.volumeManager_, this.thumbnailModel_,
this.thumbnailLoaderConstructor_);
const url = entry.toURL();
this.active_[url] = task;
task.fetch().then(thumbnail => {
delete this.active_[url];
this.cache_.put(url, thumbnail);
this.dispatchThumbnailLoaded_(index, thumbnail);
this.continue_();
});
}
/**
* Dispatches thumbnail loaded event.
*
* @param {number} index Index of an original image in the data model.
* @param {!ListThumbnailLoader.ThumbnailData} thumbnail Thumbnail.
*/
dispatchThumbnailLoaded_(index, thumbnail) {
// Update index if it's already invalid, i.e. index may be invalid if some
// change had happened in the data model during thumbnail fetch.
const item = this.dataModel_.item(index);
if (item && item.toURL() !== thumbnail.fileUrl) {
index = -1;
for (let i = 0; i < this.dataModel_.length; i++) {
if (this.dataModel_.item(i).toURL() === thumbnail.fileUrl) {
index = i;
break;
}
}
}
if (index > -1) {
this.dispatchEvent(
new ListThumbnailLoader.ThumbnailLoadedEvent(index, thumbnail));
}
}
}
/**
* Cache size. Cache size must be larger than sum of high priority range size
* and number of prefetch tasks.
* @const {number}
*/
ListThumbnailLoader.CACHE_SIZE = 500;
/**
* Volume type for testing.
* @const {string}
*/
ListThumbnailLoader.TEST_VOLUME_TYPE = 'test_volume_type';
/**
* Number of maximum active tasks for testing.
* @type {number}
*/
ListThumbnailLoader.numOfMaxActiveTasksForTest = 2;
/**
* @typedef {(VolumeManagerCommon.VolumeType|string)}
*/
ListThumbnailLoader.VolumeType;
/**
* Thumbnail loaded event.
*/
ListThumbnailLoader.ThumbnailLoadedEvent =
class ThumbnailLoadedEvent extends Event {
/**
* @param {number} index Index of an original image in the current data
* model.
* @param {!ListThumbnailLoader.ThumbnailData} thumbnail Thumbnail.
*/
constructor(index, thumbnail) {
super('thumbnailLoaded');
/** @type {number} */
this.index = index;
/** @type {string}*/
this.fileUrl = thumbnail.fileUrl;
/** @type {?string} */
this.dataUrl = thumbnail.dataUrl;
/** @type {?number} */
this.width = thumbnail.width;
/** @type {?number}*/
this.height = thumbnail.height;
}
};
/**
* A class to represent thumbnail data.
*/
ListThumbnailLoader.ThumbnailData = class {
/**
* @param {string} fileUrl File url of an original image.
* @param {?string} dataUrl Data url of thumbnail.
* @param {?number} width Width of thumbnail.
* @param {?number} height Height of thumbnail.
*/
constructor(fileUrl, dataUrl, width, height) {
/**
* @const {string}
*/
this.fileUrl = fileUrl;
/**
* @const {?string}
*/
this.dataUrl = dataUrl;
/**
* @const {?number}
*/
this.width = width;
/**
* @const {?number}
*/
this.height = height;
/**
* @type {boolean}
*/
this.outdated = false;
}
};
/**
* A task to load thumbnail.
*/
ListThumbnailLoader.Task = class {
/**
*
* @param {!Entry} entry An entry.
* @param {!VolumeManager} volumeManager Volume manager.
* @param {!ThumbnailModel} thumbnailModel Metadata cache.
* @param {!Function} thumbnailLoaderConstructor A constructor of thumbnail
* loader.
*/
constructor(
entry, volumeManager, thumbnailModel, thumbnailLoaderConstructor) {
this.entry_ = entry;
this.volumeManager_ = volumeManager;
this.thumbnailModel_ = thumbnailModel;
this.thumbnailLoaderConstructor_ = thumbnailLoaderConstructor;
}
/**
* Fetches thumbnail.
*
* @return {!Promise<!ListThumbnailLoader.ThumbnailData>} A promise which is
* resolved when thumbnail data is fetched with either a success or an
* error.
*/
fetch() {
let ioError = false;
return this.thumbnailModel_.get([this.entry_])
.then(metadatas => {
// When it failed to read exif header with an IO error, do not
// generate thumbnail at this time since it may success in the second
// try. If it failed to read at 0 byte, it would be an IO error.
if (metadatas[0].thumbnail.urlError &&
metadatas[0].thumbnail.urlError.errorDescription ===
'Error: Unexpected EOF @0') {
ioError = true;
return Promise.reject();
}
return metadatas[0];
})
.then(metadata => {
const loadTargets = [
ThumbnailLoader.LoadTarget.CONTENT_METADATA,
ThumbnailLoader.LoadTarget.EXTERNAL_METADATA
];
// If the file is on a network filesystem, don't generate thumbnails
// from file entry, as it could cause very high network traffic.
// Allow Drive to do so however, as ThumbnailLoader tries to generate
// thumbnails of Drive files from file entry only if cached locally.
const volumeInfo = this.volumeManager_.getVolumeInfo(this.entry_);
if (volumeInfo &&
(volumeInfo.source !== VolumeManagerCommon.Source.NETWORK ||
volumeInfo.volumeType ===
VolumeManagerCommon.VolumeType.DRIVE)) {
loadTargets.push(ThumbnailLoader.LoadTarget.FILE_ENTRY);
}
return new this
.thumbnailLoaderConstructor_(
this.entry_, ThumbnailLoader.LoaderType.IMAGE, metadata,
undefined /* opt_mediaType */, loadTargets)
.loadAsDataUrl(ThumbnailLoader.FillMode.OVER_FILL);
})
.then(result => {
return new ListThumbnailLoader.ThumbnailData(
this.entry_.toURL(), result.data, result.width, result.height);
})
.catch(() => {
// If an error happens during generating of a thumbnail, then return
// an empty object, so we don't retry the thumbnail over and over
// again.
const thumbnailData = new ListThumbnailLoader.ThumbnailData(
this.entry_.toURL(), null, null, null);
if (ioError) {
// If fetching a thumbnail from EXIF fails due to an IO error, then
// try to refetch it in the future, but not earlier than in 3
// second.
setTimeout(() => {
thumbnailData.outdated = true;
}, ListThumbnailLoader.Task.EXIF_IO_ERROR_DELAY);
}
return thumbnailData;
});
}
};
/**
* Minimum delay of milliseconds before another retry for fetching a
* thumbnmail from EXIF after failing with an IO error. In milliseconds.
*
* @type {number}
*/
ListThumbnailLoader.Task.EXIF_IO_ERROR_DELAY = 3000;