Bug 808277 - Show the progress of downloads that are not visible in the Downloads Panel in a summary. r=mak.
authorMike Conley <mconley@mozilla.com>
Fri, 16 Nov 2012 16:19:45 -0500
changeset 113549 5a98a1662e6494bcf4644efa6c00b406201fc936
parent 113548 d9ef44ab7ee3212714921d3826de14aba1b4818b
child 113550 15eaadc2cd9abf4dbb2ce27c3e44d047309273aa
push id18228
push usermconley@mozilla.com
push dateFri, 16 Nov 2012 21:33:24 +0000
treeherdermozilla-inbound@c2ea3fb28c45 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmak
bugs808277
milestone19.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 808277 - Show the progress of downloads that are not visible in the Downloads Panel in a summary. r=mak.
browser/components/downloads/content/download.xml
browser/components/downloads/content/downloads.css
browser/components/downloads/content/downloads.js
browser/components/downloads/content/downloadsOverlay.xul
browser/components/downloads/src/DownloadsCommon.jsm
browser/locales/en-US/chrome/browser/downloads/downloads.dtd
browser/locales/en-US/chrome/browser/downloads/downloads.properties
browser/themes/gnomestripe/downloads/downloads.css
browser/themes/pinstripe/downloads/downloads.css
browser/themes/winstripe/downloads/downloads.css
--- a/browser/components/downloads/content/download.xml
+++ b/browser/components/downloads/content/download.xml
@@ -19,18 +19,26 @@
              align="center"
              onclick="DownloadsView.onDownloadClick(event);">
       <xul:image class="downloadTypeIcon"
                  validate="always"
                  xbl:inherits="src=image"/>
       <xul:image class="downloadTypeIcon blockedIcon"/>
       <xul:vbox pack="center"
                 flex="1">
+        <!-- We're letting localizers put a min-width in here primarily
+             because of the downloads summary at the bottom of the list of
+             download items. An element in the summary has the same min-width
+             on a description, and we don't want the panel to change size if the
+             summary isn't being displayed, so we ensure that items share the
+             same minimum width.
+             -->
         <xul:description class="downloadTarget"
                          crop="center"
+                         style="min-width: &downloadsSummary.minWidth;"
                          xbl:inherits="value=target,tooltiptext=target"/>
         <xul:progressmeter anonid="progressmeter"
                            class="downloadProgress"
                            min="0"
                            max="100"
                            xbl:inherits="mode=progressmode,value=progress"/>
         <xul:description class="downloadDetails"
                          style="width: &downloadDetails.width;"
--- a/browser/components/downloads/content/downloads.css
+++ b/browser/components/downloads/content/downloads.css
@@ -84,8 +84,13 @@ richlistitem[type="download"]:not([selec
 #downloads-indicator:not(:-moz-any([progress],
                                    [counter],
                                    [paused]))
                                            #downloads-indicator-progress-area
 
 {
   visibility: hidden;
 }
+
+#downloadsSummary:not([inprogress="true"]) #downloadsSummaryProgress,
+#downloadsSummary:not([inprogress="true"]) #downloadsSummaryDetails {
+  display: none;
+}
--- a/browser/components/downloads/content/downloads.js
+++ b/browser/components/downloads/content/downloads.js
@@ -535,21 +535,20 @@ const DownloadsView = {
     let hiddenCount = count - this.kItemCountLimit;
 
     if (count > 0) {
       DownloadsPanel.panel.setAttribute("hasdownloads", "true");
     } else {
       DownloadsPanel.panel.removeAttribute("hasdownloads");
     }
 
-    let s = DownloadsCommon.strings;
-    this.downloadsHistory.label = (hiddenCount > 0)
-                                  ? s.showMoreDownloads(hiddenCount)
-                                  : s.showAllDownloads;
-    this.downloadsHistory.accessKey = s.showDownloadsAccessKey;
+    // If we've got some hidden downloads, we should show the summary just
+    // below the list.
+    this.downloadsHistory.collapsed = hiddenCount > 0;
+    DownloadsSummary.visible = this.downloadsHistory.collapsed;
   },
 
   /**
    * Element corresponding to the list of downloads.
    */
   get richListBox()
   {
     delete this.richListBox;
@@ -1404,8 +1403,160 @@ DownloadsViewItemController.prototype = 
    */
   _openExternal: function DVIC_openExternal(aFile)
   {
     let protocolSvc = Cc["@mozilla.org/uriloader/external-protocol-service;1"]
                       .getService(Ci.nsIExternalProtocolService);
     protocolSvc.loadUrl(makeFileURI(aFile));
   }
 };
+
+
+////////////////////////////////////////////////////////////////////////////////
+//// DownloadsSummary
+
+/**
+ * Manages the summary at the bottom of the downloads panel list if the number
+ * of items in the list exceeds the panels limit.
+ */
+const DownloadsSummary = {
+
+  /**
+   * Sets the collapsed state of the summary, and automatically subscribes or
+   * unsubscribes from the DownloadsCommon DownloadsSummaryData singleton.
+   *
+   * @param aVisible
+   *        True if the summary should be shown.
+   */
+  set visible(aVisible)
+  {
+    if (aVisible == this._visible || !this._summaryNode) {
+      return;
+    }
+    if (aVisible) {
+      DownloadsCommon.getSummary(DownloadsView.kItemCountLimit)
+                     .addView(this);
+    } else {
+      DownloadsCommon.getSummary(DownloadsView.kItemCountLimit)
+                     .removeView(this);
+    }
+    this._summaryNode.collapsed = !aVisible;
+    return this._visible = aVisible;
+  },
+  _visible: false,
+
+  /**
+   * Sets whether or not we show the progress bar.
+   *
+   * @param aShowingProgress
+   *        True if we should show the progress bar.
+   */
+  set showingProgress(aShowingProgress)
+  {
+    if (aShowingProgress) {
+      this._summaryNode.setAttribute("inprogress", "true");
+    } else {
+      this._summaryNode.removeAttribute("inprogress");
+    }
+  },
+
+  /**
+   * Sets the amount of progress that is visible in the progress bar.
+   *
+   * @param aValue
+   *        A value between 0 and 100 to represent the progress of the
+   *        summarized downloads.
+   */
+  set percentComplete(aValue)
+  {
+    if (this._progressNode) {
+      this._progressNode.setAttribute("value", aValue);
+    }
+    return aValue;
+  },
+
+  /**
+   * Sets the description for the download summary.
+   *
+   * @param aValue
+   *        A string representing the description of the summarized
+   *        downloads.
+   */
+  set description(aValue)
+  {
+    if (this._descriptionNode) {
+      this._descriptionNode.setAttribute("value", aValue);
+      this._descriptionNode.setAttribute("tooltiptext", aValue);
+    }
+    return aValue;
+  },
+
+  /**
+   * Sets the details for the download summary, such as the time remaining,
+   * the amount of bytes transferred, etc.
+   *
+   * @param aValue
+   *        A string representing the details of the summarized
+   *        downloads.
+   */
+  set details(aValue)
+  {
+    if (this._detailsNode) {
+      this._detailsNode.setAttribute("value", aValue);
+      this._detailsNode.setAttribute("tooltiptext", aValue);
+    }
+    return aValue;
+  },
+
+  /**
+   * Element corresponding to the root of the downloads summary.
+   */
+  get _summaryNode()
+  {
+    let node = document.getElementById("downloadsSummary");
+    if (!node) {
+      return null;
+    }
+    delete this._summaryNode;
+    return this._summaryNode = node;
+  },
+
+  /**
+   * Element corresponding to the progress bar in the downloads summary.
+   */
+  get _progressNode()
+  {
+    let node = document.getElementById("downloadsSummaryProgress");
+    if (!node) {
+      return null;
+    }
+    delete this._progressNode;
+    return this._progressNode = node;
+  },
+
+  /**
+   * Element corresponding to the main description of the downloads
+   * summary.
+   */
+  get _descriptionNode()
+  {
+    let node = document.getElementById("downloadsSummaryDescription");
+    if (!node) {
+      return null;
+    }
+    delete this._descriptionNode;
+    return this._descriptionNode = node;
+  },
+
+  /**
+   * Element corresponding to the secondary description of the downloads
+   * summary.
+   */
+  get _detailsNode()
+  {
+    let node = document.getElementById("downloadsSummaryDetails");
+    if (!node) {
+      return null;
+    }
+    delete this._detailsNode;
+    return this._detailsNode = node;
+  }
+}
--- a/browser/components/downloads/content/downloadsOverlay.xul
+++ b/browser/components/downloads/content/downloadsOverlay.xul
@@ -100,14 +100,38 @@
       <richlistbox id="downloadsListBox"
                    class="plain"
                    flex="1"
                    context="downloadsContextMenu"
                    onkeypress="DownloadsView.onDownloadKeyPress(event);"
                    oncontextmenu="DownloadsView.onDownloadContextMenu(event);"
                    ondragstart="DownloadsView.onDownloadDragStart(event);"/>
 
+      <hbox id="downloadsSummary"
+            collapsed="true"
+            align="center"
+            orient="horizontal"
+            onclick="DownloadsPanel.showDownloadsHistory();">
+        <image class="downloadTypeIcon" />
+        <vbox>
+          <description id="downloadsSummaryDescription"
+                       class="downloadTarget"
+                       style="min-width: &downloadsSummary.minWidth;"/>
+          <progressmeter id="downloadsSummaryProgress"
+                         class="downloadProgress"
+                         min="0"
+                         max="100"
+                         mode="normal" />
+          <description id="downloadsSummaryDetails"
+                       class="downloadDetails"
+                       style="width: &downloadDetails.width;"
+                       crop="end"/>
+        </vbox>
+      </hbox>
+
       <button id="downloadsHistory"
               class="plain"
+              label="&downloadsHistory.label;"
+              accesskey="&downloadsHistory.accesskey;"
               oncommand="DownloadsPanel.showDownloadsHistory();"/>
     </panel>
   </popupset>
 </overlay>
--- a/browser/components/downloads/src/DownloadsCommon.jsm
+++ b/browser/components/downloads/src/DownloadsCommon.jsm
@@ -49,16 +49,18 @@ Cu.import("resource://gre/modules/Servic
 
 XPCOMUtils.defineLazyServiceGetter(this, "gBrowserGlue",
                                    "@mozilla.org/browser/browserglue;1",
                                    "nsIBrowserGlue");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
                                   "resource://gre/modules/PluralForm.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "DownloadUtils",
+                                  "resource://gre/modules/DownloadUtils.jsm");
 
 const nsIDM = Ci.nsIDownloadManager;
 
 const kDownloadsStringBundleUrl =
   "chrome://browser/locale/downloads/downloads.properties";
 
 const kDownloadsStringsRequiringFormatting = {
   sizeWithUnits: true,
@@ -67,17 +69,17 @@ const kDownloadsStringsRequiringFormatti
   shortTimeLeftHours: true,
   shortTimeLeftDays: true,
   statusSeparator: true,
   statusSeparatorBeforeNumber: true,
   fileExecutableSecurityWarning: true
 };
 
 const kDownloadsStringsRequiringPluralForm = {
-  showMoreDownloads: true
+  otherDownloads: true
 };
 
 XPCOMUtils.defineLazyGetter(this, "DownloadsLocalFileCtor", function () {
   return Components.Constructor("@mozilla.org/file/local;1",
                                 "nsILocalFile", "initWithPath");
 });
 
 const kPartialDownloadSuffix = ".part";
@@ -179,17 +181,155 @@ this.DownloadsCommon = {
   get data() DownloadsData,
 
   /**
    * Returns a reference to the DownloadsData singleton.
    *
    * This does not need to be a lazy getter, since no initialization is required
    * at present.
    */
-  get indicatorData() DownloadsIndicatorData
+  get indicatorData() DownloadsIndicatorData,
+
+  /**
+   * Returns a reference to the DownloadsSummaryData singleton - creating one
+   * in the process if one hasn't been instantiated yet.
+   *
+   * @param aNumToExclude
+   *        The number of items on the top of the downloads list to exclude
+   *        from the summary.
+   */
+  _summary: null,
+  getSummary: function DC_getSummary(aNumToExclude)
+  {
+    if (this._summary) {
+      return this._summary;
+    }
+    return this._summary = new DownloadsSummaryData(aNumToExclude);
+  },
+
+  /**
+   * Given an iterable collection of nsIDownload's, generates and returns
+   * statistics about that collection.
+   *
+   * @param aDownloads An iterable collection of nsIDownloads.
+   *
+   * @return Object whose properties are the generated statistics. Currently,
+   *         we return the following properties:
+   *
+   *         numActive       : The total number of downloads.
+   *         numPaused       : The total number of paused downloads.
+   *         numScanning     : The total number of downloads being scanned.
+   *         numDownloading  : The total number of downloads being downloaded.
+   *         totalSize       : The total size of all downloads once completed.
+   *         totalTransferred: The total amount of transferred data for these
+   *                           downloads.
+   *         slowestSpeed    : The slowest download rate.
+   *         rawTimeLeft     : The estimated time left for the downloads to
+   *                           complete.
+   *         percentComplete : The percentage of bytes successfully downloaded.
+   */
+  summarizeDownloads: function DC_summarizeDownloads(aDownloads)
+  {
+    let summary = {
+      numActive: 0,
+      numPaused: 0,
+      numScanning: 0,
+      numDownloading: 0,
+      totalSize: 0,
+      totalTransferred: 0,
+      // slowestSpeed is Infinity so that we can use Math.min to
+      // find the slowest speed. We'll set this to 0 afterwards if
+      // it's still at Infinity by the time we're done iterating all
+      // downloads.
+      slowestSpeed: Infinity,
+      rawTimeLeft: -1,
+      percentComplete: -1
+    }
+
+    // If no download has been loaded, don't use the methods of the Download
+    // Manager service, so that it is not initialized unnecessarily.
+    for (let download of aDownloads) {
+      summary.numActive++;
+      switch (download.state) {
+        case nsIDM.DOWNLOAD_PAUSED:
+          summary.numPaused++;
+          break;
+        case nsIDM.DOWNLOAD_SCANNING:
+          summary.numScanning++;
+          break;
+        case nsIDM.DOWNLOAD_DOWNLOADING:
+          summary.numDownloading++;
+          if (download.size > 0 && download.speed > 0) {
+            let sizeLeft = download.size - download.amountTransferred;
+            summary.rawTimeLeft = Math.max(summary.rawTimeLeft,
+                                           sizeLeft / download.speed);
+            summary.slowestSpeed = Math.min(summary.slowestSpeed,
+                                            download.speed);
+          }
+          break;
+      }
+      // Only add to total values if we actually know the download size.
+      if (download.size > 0 &&
+          download.state != nsIDM.DOWNLOAD_CANCELED &&
+          download.state != nsIDM.DOWNLOAD_FAILED) {
+        summary.totalSize += download.size;
+        summary.totalTransferred += download.amountTransferred;
+      }
+    }
+
+    if (summary.numActive != 0 && summary.totalSize != 0 &&
+        summary.numActive != summary.numScanning) {
+      summary.percentComplete = (summary.totalTransferred /
+                                 summary.totalSize) * 100;
+    }
+
+    if (summary.slowestSpeed == Infinity) {
+      summary.slowestSpeed = 0;
+    }
+
+    return summary;
+  },
+
+  /**
+   * If necessary, smooths the estimated number of seconds remaining for one
+   * or more downloads to complete.
+   *
+   * @param aSeconds
+   *        Current raw estimate on number of seconds left for one or more
+   *        downloads. This is a floating point value to help get sub-second
+   *        accuracy for current and future estimates.
+   */
+  smoothSeconds: function DC_smoothSeconds(aSeconds, aLastSeconds)
+  {
+    // We apply an algorithm similar to the DownloadUtils.getTimeLeft function,
+    // though tailored to a single time estimation for all downloads.  We never
+    // apply sommothing if the new value is less than half the previous value.
+    let shouldApplySmoothing = aLastSeconds >= 0 &&
+                               aSeconds > aLastSeconds / 2;
+    if (shouldApplySmoothing) {
+      // Apply hysteresis to favor downward over upward swings.  Trust only 30%
+      // of the new value if lower, and 10% if higher (exponential smoothing).
+      let (diff = aSeconds - aLastSeconds) {
+        aSeconds = aLastSeconds + (diff < 0 ? .3 : .1) * diff;
+      }
+
+      // If the new time is similar, reuse something close to the last time
+      // left, but subtract a little to provide forward progress.
+      let diff = aSeconds - aLastSeconds;
+      let diffPercent = diff / aLastSeconds * 100;
+      if (Math.abs(diff) < 5 || Math.abs(diffPercent) < 5) {
+        aSeconds = aLastSeconds - (diff < 0 ? .4 : .2);
+      }
+    }
+
+    // In the last few seconds of downloading, we are always subtracting and
+    // never adding to the time left.  Ensure that we never fall below one
+    // second left until all downloads are actually finished.
+    return aLastSeconds = Math.max(aSeconds, 1);
+  }
 };
 
 /**
  * Returns true if we are executing on Windows Vista or a later version.
  */
 XPCOMUtils.defineLazyGetter(DownloadsCommon, "isWinVistaOrHigher", function () {
   let os = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime).OS;
   if (os != "WINNT") {
@@ -608,16 +748,17 @@ const DownloadsData = {
     }
 
     dataItem.state = aDownload.state;
     dataItem.referrer = aDownload.referrer && aDownload.referrer.spec;
     dataItem.resumable = aDownload.resumable;
     dataItem.startTime = Math.round(aDownload.startTime / 1000);
     dataItem.currBytes = aDownload.amountTransferred;
     dataItem.maxBytes = aDownload.size;
+    dataItem.download = aDownload;
 
     this._views.forEach(
       function (view) view.getViewItem(dataItem).onStateChange()
     );
 
     if (isNew && !dataItem.newDownloadNotified) {
       dataItem.newDownloadNotified = true;
       this._notifyNewDownload();
@@ -914,112 +1055,231 @@ DownloadsDataItem.prototype = {
       // The downloads database contains a native path.  Try to create a local
       // file, though this may throw an exception if the path is invalid.
       return new DownloadsLocalFileCtor(aFilename);
     }
   }
 };
 
 ////////////////////////////////////////////////////////////////////////////////
-//// DownloadsIndicatorData
+//// DownloadsViewPrototype
 
 /**
- * This object registers itself with DownloadsData as a view, and transforms the
- * notifications it receives into overall status data, that is then broadcast to
- * the registered download status indicators.
- *
- * Note that using this object does not automatically start the Download Manager
- * service.  Consumers will see an empty list of downloads until the service is
- * actually started.  This is useful to display a neutral progress indicator in
- * the main browser window until the autostart timeout elapses.
+ * A prototype for an object that registers itself with DownloadsData as soon
+ * as a view is registered with it.
  */
-const DownloadsIndicatorData = {
+const DownloadsViewPrototype = {
   //////////////////////////////////////////////////////////////////////////////
   //// Registration of views
 
   /**
    * Array of view objects that should be notified when the available status
    * data changes.
    */
   _views: [],
 
   /**
    * Adds an object to be notified when the available status data changes.
    * The specified object is initialized with the currently available status.
    *
    * @param aView
-   *        DownloadsIndicatorView object to be added.  This reference must be
+   *        View object to be added.  This reference must be
    *        passed to removeView before termination.
    */
-  addView: function DID_addView(aView)
+  addView: function DVP_addView(aView)
   {
     // Start receiving events when the first of our views is registered.
     if (this._views.length == 0) {
       DownloadsCommon.data.addView(this);
     }
 
     this._views.push(aView);
     this.refreshView(aView);
   },
 
   /**
    * Updates the properties of an object previously added using addView.
    *
    * @param aView
-   *        DownloadsIndicatorView object to be updated.
+   *        View object to be updated.
    */
-  refreshView: function DID_refreshView(aView)
+  refreshView: function DVP_refreshView(aView)
   {
     // Update immediately even if we are still loading data asynchronously.
+    // Subclasses must provide these two functions!
     this._refreshProperties();
     this._updateView(aView);
   },
 
   /**
    * Removes an object previously added using addView.
    *
    * @param aView
-   *        DownloadsIndicatorView object to be removed.
+   *        View object to be removed.
    */
-  removeView: function DID_removeView(aView)
+  removeView: function DVP_removeView(aView)
   {
     let index = this._views.indexOf(aView);
     if (index != -1) {
       this._views.splice(index, 1);
     }
 
     // Stop receiving events when the last of our views is unregistered.
     if (this._views.length == 0) {
       DownloadsCommon.data.removeView(this);
-      this._itemCount = 0;
     }
   },
 
   //////////////////////////////////////////////////////////////////////////////
   //// Callback functions from DownloadsData
 
   /**
    * Indicates whether we are still loading downloads data asynchronously.
    */
   _loading: false,
 
   /**
    * Called before multiple downloads are about to be loaded.
    */
-  onDataLoadStarting: function DID_onDataLoadStarting()
+  onDataLoadStarting: function DVP_onDataLoadStarting()
   {
     this._loading = true;
   },
 
   /**
    * Called after data loading finished.
    */
+  onDataLoadCompleted: function DVP_onDataLoadCompleted()
+  {
+    this._loading = false;
+  },
+
+  /**
+   * Called when the downloads database becomes unavailable (for example, we
+   * entered Private Browsing Mode and the database backend changed).
+   * References to existing data should be discarded.
+   *
+   * @note Subclasses should override this.
+   */
+  onDataInvalidated: function DVP_onDataInvalidated()
+  {
+    throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
+  },
+
+  /**
+   * Called when a new download data item is available, either during the
+   * asynchronous data load or when a new download is started.
+   *
+   * @param aDataItem
+   *        DownloadsDataItem object that was just added.
+   * @param aNewest
+   *        When true, indicates that this item is the most recent and should be
+   *        added in the topmost position.  This happens when a new download is
+   *        started.  When false, indicates that the item is the least recent
+   *        with regard to the items that have been already added. The latter
+   *        generally happens during the asynchronous data load.
+   *
+   * @note Subclasses should override this.
+   */
+  onDataItemAdded: function DVP_onDataItemAdded(aDataItem, aNewest)
+  {
+    throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
+  },
+
+  /**
+   * Called when a data item is removed, ensures that the widget associated with
+   * the view item is removed from the user interface.
+   *
+   * @param aDataItem
+   *        DownloadsDataItem object that is being removed.
+   *
+   * @note Subclasses should override this.
+   */
+  onDataItemRemoved: function DVP_onDataItemRemoved(aDataItem)
+  {
+    throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
+  },
+
+  /**
+   * Returns the view item associated with the provided data item for this view.
+   *
+   * @param aDataItem
+   *        DownloadsDataItem object for which the view item is requested.
+   *
+   * @return Object that can be used to notify item status events.
+   *
+   * @note Subclasses should override this.
+   */
+  getViewItem: function DID_getViewItem(aDataItem)
+  {
+    throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
+  },
+
+  /**
+   * Private function used to refresh the internal properties being sent to
+   * each registered view.
+   *
+   * @note Subclasses should override this.
+   */
+  _refreshProperties: function DID_refreshProperties()
+  {
+    throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
+  },
+
+  /**
+   * Private function used to refresh an individual view.
+   *
+   * @note Subclasses should override this.
+   */
+  _updateView: function DID_updateView()
+  {
+    throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
+  }
+};
+
+////////////////////////////////////////////////////////////////////////////////
+//// DownloadsIndicatorData
+
+/**
+ * This object registers itself with DownloadsData as a view, and transforms the
+ * notifications it receives into overall status data, that is then broadcast to
+ * the registered download status indicators.
+ *
+ * Note that using this object does not automatically start the Download Manager
+ * service.  Consumers will see an empty list of downloads until the service is
+ * actually started.  This is useful to display a neutral progress indicator in
+ * the main browser window until the autostart timeout elapses.
+ */
+const DownloadsIndicatorData = {
+  __proto__: DownloadsViewPrototype,
+
+  /**
+   * Removes an object previously added using addView.
+   *
+   * @param aView
+   *        DownloadsIndicatorView object to be removed.
+   */
+  removeView: function DID_removeView(aView)
+  {
+    DownloadsViewPrototype.removeView.call(this, aView);
+
+    if (this._views.length == 0) {
+      this._itemCount = 0;
+    }
+  },
+
+  //////////////////////////////////////////////////////////////////////////////
+  //// Callback functions from DownloadsData
+
+  /**
+   * Called after data loading finished.
+   */
   onDataLoadCompleted: function DID_onDataLoadCompleted()
   {
-    this._loading = false;
+    DownloadsViewPrototype.onDataLoadCompleted.call(this);
     this._updateViews();
   },
 
   /**
    * Called when the downloads database becomes unavailable (for example, we
    * entered Private Browsing Mode and the database backend changed).
    * References to existing data should be discarded.
    */
@@ -1174,115 +1434,263 @@ const DownloadsIndicatorData = {
    * Last number of seconds estimated until all in-progress downloads with a
    * known size and speed will finish.  This value is stored to allow smoothing
    * in case of small variations.  This is set to -1 if the previous value is
    * unknown.
    */
   _lastTimeLeft: -1,
 
   /**
-   * Update the estimated time until all in-progress downloads will finish.
-   *
-   * @param aSeconds
-   *        Current raw estimate on number of seconds left for all downloads.
-   *        This is a floating point value to help get sub-second accuracy for
-   *        current and future estimates.
+   * A generator function for the downloads that this summary is currently
+   * interested in. This generator is passed off to summarizeDownloads in order
+   * to generate statistics about the downloads we care about - in this case,
+   * it's all active downloads.
    */
-  _updateTimeLeft: function DID_updateTimeLeft(aSeconds)
+  _activeDownloads: function DID_activeDownloads()
   {
-    // We apply an algorithm similar to the DownloadUtils.getTimeLeft function,
-    // though tailored to a single time estimation for all downloads.  We never
-    // apply sommothing if the new value is less than half the previous value.
-    let shouldApplySmoothing = this._lastTimeLeft >= 0 &&
-                               aSeconds > this._lastTimeLeft / 2;
-    if (shouldApplySmoothing) {
-      // Apply hysteresis to favor downward over upward swings.  Trust only 30%
-      // of the new value if lower, and 10% if higher (exponential smoothing).
-      let (diff = aSeconds - this._lastTimeLeft) {
-        aSeconds = this._lastTimeLeft + (diff < 0 ? .3 : .1) * diff;
-      }
-
-      // If the new time is similar, reuse something close to the last time
-      // left, but subtract a little to provide forward progress.
-      let diff = aSeconds - this._lastTimeLeft;
-      let diffPercent = diff / this._lastTimeLeft * 100;
-      if (Math.abs(diff) < 5 || Math.abs(diffPercent) < 5) {
-        aSeconds = this._lastTimeLeft - (diff < 0 ? .4 : .2);
+    // If no download has been loaded, don't use the methods of the Download
+    // Manager service, so that it is not initialized unnecessarily.
+    if (this._itemCount > 0) {
+      let downloads = Services.downloads.activeDownloads;
+      while (downloads.hasMoreElements()) {
+        yield downloads.getNext().QueryInterface(Ci.nsIDownload);
       }
     }
-
-    // In the last few seconds of downloading, we are always subtracting and
-    // never adding to the time left.  Ensure that we never fall below one
-    // second left until all downloads are actually finished.
-    this._lastTimeLeft = Math.max(aSeconds, 1);
   },
 
   /**
    * Computes aggregate values based on the current state of downloads.
    */
   _refreshProperties: function DID_refreshProperties()
   {
-    let numActive = 0;
-    let numPaused = 0;
-    let numScanning = 0;
-    let totalSize = 0;
-    let totalTransferred = 0;
-    let rawTimeLeft = -1;
-
-    // If no download has been loaded, don't use the methods of the Download
-    // Manager service, so that it is not initialized unnecessarily.
-    if (this._itemCount > 0) {
-      let downloads = Services.downloads.activeDownloads;
-      while (downloads.hasMoreElements()) {
-        let download = downloads.getNext().QueryInterface(Ci.nsIDownload);
-        numActive++;
-        switch (download.state) {
-          case nsIDM.DOWNLOAD_PAUSED:
-            numPaused++;
-            break;
-          case nsIDM.DOWNLOAD_SCANNING:
-            numScanning++;
-            break;
-          case nsIDM.DOWNLOAD_DOWNLOADING:
-            if (download.size > 0 && download.speed > 0) {
-              let sizeLeft = download.size - download.amountTransferred;
-              rawTimeLeft = Math.max(rawTimeLeft, sizeLeft / download.speed);
-            }
-            break;
-        }
-        // Only add to total values if we actually know the download size.
-        if (download.size > 0) {
-          totalSize += download.size;
-          totalTransferred += download.amountTransferred;
-        }
-      }
-    }
+    let summary =
+      DownloadsCommon.summarizeDownloads(this._activeDownloads());
 
     // Determine if the indicator should be shown or get attention.
     this._hasDownloads = (this._itemCount > 0);
 
-    if (numActive == 0 || totalSize == 0 || numActive == numScanning) {
-      // Don't display the current progress.
-      this._percentComplete = -1;
-    } else {
-      // Display the current progress.
-      this._percentComplete = (totalTransferred / totalSize) * 100;
-    }
+    // If all downloads are paused, show the progress indicator as paused.
+    this._paused = summary.numActive > 0 &&
+                   summary.numActive == summary.numPaused;
 
-    // If all downloads are paused, show the progress indicator as paused.
-    this._paused = numActive > 0 && numActive == numPaused;
+    this._percentComplete = summary.percentComplete;
 
     // Display the estimated time left, if present.
-    if (rawTimeLeft == -1) {
+    if (summary.rawTimeLeft == -1) {
       // There are no downloads with a known time left.
       this._lastRawTimeLeft = -1;
       this._lastTimeLeft = -1;
       this._counter = "";
     } else {
       // Compute the new time left only if state actually changed.
-      if (this._lastRawTimeLeft != rawTimeLeft) {
-        this._lastRawTimeLeft = rawTimeLeft;
-        this._updateTimeLeft(rawTimeLeft);
+      if (this._lastRawTimeLeft != summary.rawTimeLeft) {
+        this._lastRawTimeLeft = summary.rawTimeLeft;
+        this._lastTimeLeft = DownloadsCommon.smoothSeconds(summary.rawTimeLeft,
+                                                           this._lastTimeLeft);
       }
       this._counter = DownloadsCommon.formatTimeLeft(this._lastTimeLeft);
     }
   }
 }
+
+////////////////////////////////////////////////////////////////////////////////
+//// DownloadsSummaryData
+
+/**
+ * DownloadsSummaryData is a view for DownloadsData that produces a summary
+ * of all downloads after a certain exclusion point aNumToExclude. For example,
+ * if there were 5 downloads in progress, and a DownloadsSummaryData was
+ * constructed with aNumToExclude equal to 3, then that DownloadsSummaryData
+ * would produce a summary of the last 2 downloads.
+ *
+ * @param aNumToExclude
+ *        The number of items to exclude from the summary, starting from the
+ *        top of the list.
+ */
+function DownloadsSummaryData(aNumToExclude) {
+  this._numToExclude = aNumToExclude;
+  // Since we can have multiple instances of DownloadsSummaryData, we
+  // override these values from the prototype so that each instance can be
+  // completely separated from one another.
+  this._views = [];
+  this._loading = false;
+
+  this._dataItems = [];
+
+  // Floating point value indicating the last number of seconds estimated until
+  // the longest download will finish.  We need to store this value so that we
+  // don't continuously apply smoothing if the actual download state has not
+  // changed.  This is set to -1 if the previous value is unknown.
+  this._lastRawTimeLeft = -1;
+
+  // Last number of seconds estimated until all in-progress downloads with a
+  // known size and speed will finish.  This value is stored to allow smoothing
+  // in case of small variations.  This is set to -1 if the previous value is
+  // unknown.
+  this._lastTimeLeft = -1;
+
+  // The following properties are updated by _refreshProperties and are then
+  // propagated to the views.
+  this._showingProgress = false;
+  this._details = "";
+  this._description = "";
+  this._numActive = 0;
+  this._percentComplete = -1;
+}
+
+DownloadsSummaryData.prototype = {
+  __proto__: DownloadsViewPrototype,
+
+  /**
+   * Removes an object previously added using addView.
+   *
+   * @param aView
+   *        DownloadsSummary view to be removed.
+   */
+  removeView: function DSD_removeView(aView)
+  {
+    DownloadsViewPrototype.removeView.call(this, aView);
+
+    if (this._views.length == 0) {
+      // Clear out our collection of DownloadsDataItems. If we ever have
+      // another view registered with us, this will get re-populated.
+      this._dataItems = [];
+    }
+  },
+
+  //////////////////////////////////////////////////////////////////////////////
+  //// Callback functions from DownloadsData - see the documentation in
+  //// DownloadsViewPrototype for more information on what these functions
+  //// are used for.
+
+  onDataLoadCompleted: function DSD_onDataLoadCompleted()
+  {
+    DownloadsViewPrototype.onDataLoadCompleted.call(this);
+    this._updateViews();
+  },
+
+  onDataInvalidated: function DSD_onDataInvalidated()
+  {
+    this._dataItems = [];
+  },
+
+  onDataItemAdded: function DSD_onDataItemAdded(aDataItem, aNewest)
+  {
+    if (aNewest) {
+      this._dataItems.unshift(aDataItem);
+    } else {
+      this._dataItems.push(aDataItem);
+    }
+
+    this._updateViews();
+  },
+
+  onDataItemRemoved: function DSD_onDataItemRemoved(aDataItem)
+  {
+    let itemIndex = this._dataItems.indexOf(aDataItem);
+    this._dataItems.splice(itemIndex, 1);
+    this._updateViews();
+  },
+
+  getViewItem: function DSD_getViewItem(aDataItem)
+  {
+    let self = this;
+    return Object.freeze({
+      onStateChange: function DIVI_onStateChange()
+      {
+        // Since the state of a download changed, reset the estimated time left.
+        self._lastRawTimeLeft = -1;
+        self._lastTimeLeft = -1;
+        self._updateViews();
+      },
+      onProgressChange: function DIVI_onProgressChange()
+      {
+        self._updateViews();
+      }
+    });
+  },
+
+  //////////////////////////////////////////////////////////////////////////////
+  //// Propagation of properties to our views
+
+  /**
+   * Computes aggregate values and propagates the changes to our views.
+   */
+  _updateViews: function DSD_updateViews()
+  {
+    // Do not update the status indicators during batch loads of download items.
+    if (this._loading) {
+      return;
+    }
+
+    this._refreshProperties();
+    this._views.forEach(this._updateView, this);
+  },
+
+  /**
+   * Updates the specified view with the current aggregate values.
+   *
+   * @param aView
+   *        DownloadsIndicatorView object to be updated.
+   */
+  _updateView: function DSD_updateView(aView)
+  {
+    aView.showingProgress = this._showingProgress;
+    aView.percentComplete = this._percentComplete;
+    aView.description = this._description;
+    aView.details = this._details;
+  },
+
+  //////////////////////////////////////////////////////////////////////////////
+  //// Property updating based on current download status
+
+  /**
+   * A generator function for the downloads that this summary is currently
+   * interested in. This generator is passed off to summarizeDownloads in order
+   * to generate statistics about the downloads we care about - in this case,
+   * it's the downloads in this._dataItems after the first few to exclude,
+   * which was set when constructing this DownloadsSummaryData instance.
+   */
+  _downloadsForSummary: function DSD_downloadsForSummary()
+  {
+    if (this._dataItems.length > 0) {
+      for (let i = this._numToExclude; i < this._dataItems.length; ++i) {
+        yield this._dataItems[i].download;
+      }
+    }
+  },
+
+  /**
+   * Computes aggregate values based on the current state of downloads.
+   */
+  _refreshProperties: function DSD_refreshProperties()
+  {
+    // Pre-load summary with default values.
+    let summary =
+      DownloadsCommon.summarizeDownloads(this._downloadsForSummary());
+
+    this._description = DownloadsCommon.strings
+                                       .otherDownloads(summary.numActive);
+    this._percentComplete = summary.percentComplete;
+
+    // If all downloads are paused, show the progress indicator as paused.
+    this._showingProgress = summary.numDownloading > 0 ||
+                            summary.numPaused > 0;
+
+    // Display the estimated time left, if present.
+    if (summary.rawTimeLeft == -1) {
+      // There are no downloads with a known time left.
+      this._lastRawTimeLeft = -1;
+      this._lastTimeLeft = -1;
+      this._details = "";
+    } else {
+      // Compute the new time left only if state actually changed.
+      if (this._lastRawTimeLeft != summary.rawTimeLeft) {
+        this._lastRawTimeLeft = summary.rawTimeLeft;
+        this._lastTimeLeft = DownloadsCommon.smoothSeconds(summary.rawTimeLeft,
+                                                           this._lastTimeLeft);
+      }
+      [this._details] = DownloadUtils.getDownloadStatusNoRate(
+        summary.totalTransferred, summary.totalSize, summary.slowestSpeed,
+        this._lastTimeLeft);
+    }
+  }
+}
--- a/browser/locales/en-US/chrome/browser/downloads/downloads.dtd
+++ b/browser/locales/en-US/chrome/browser/downloads/downloads.dtd
@@ -20,16 +20,31 @@
      For example, in English, a long string would be:
 
      59 minutes, 59 seconds remaining - 1022 of 1023 KB
 
      That's 50 characters, so we set the width at 50ch.
      -->
 <!ENTITY downloadDetails.width            "50ch">
 
+<!-- LOCALIZATION NOTE (downloadsSummary.minWidth):
+     Minimum width for the main description of the downloads summary,
+     which is displayed at the bottom of the Downloads Panel if the
+     number of downloads exceeds the limit that the panel can display.
+
+     A good rule of thumb here is to look at the otherDownloads string
+     in downloads.properties, and make a reasonable estimate of its
+     maximum length. For English, this seems like a reasonable limit:
+
+     +999 other current downloads
+
+     that's 28 characters, so we set the minimum width to 28ch.
+     -->
+<!ENTITY downloadsSummary.minWidth        "28ch">
+
 <!ENTITY cmd.pause.label                  "Pause">
 <!ENTITY cmd.pause.accesskey              "P">
 <!ENTITY cmd.resume.label                 "Resume">
 <!ENTITY cmd.resume.accesskey             "R">
 <!ENTITY cmd.cancel.label                 "Cancel">
 <!ENTITY cmd.cancel.accesskey             "C">
 <!-- LOCALIZATION NOTE (cmd.show.label, cmd.show.accesskey, cmd.showMac.label,
      cmd.showMac.accesskey):
@@ -45,8 +60,15 @@
 <!ENTITY cmd.goToDownloadPage.accesskey   "G">
 <!ENTITY cmd.copyDownloadLink.label       "Copy Download Link">
 <!ENTITY cmd.copyDownloadLink.accesskey   "L">
 <!ENTITY cmd.removeFromList.label         "Remove From List">
 <!ENTITY cmd.removeFromList.accesskey     "e">
 <!ENTITY cmd.clearList.label              "Clear List">
 <!ENTITY cmd.clearList.accesskey          "a">
 
+<!-- LOCALIZATION NOTE (downloadsHistory.label, downloadsHistory.accesskey):
+     This string is shown at the bottom of the Downloads Panel when all the
+     downloads fit in the available space, or when there are no downloads in
+     the panel at all.
+     -->
+<!ENTITY downloadsHistory.label           "Show All Downloads">
+<!ENTITY downloadsHistory.accesskey       "S">
--- a/browser/locales/en-US/chrome/browser/downloads/downloads.properties
+++ b/browser/locales/en-US/chrome/browser/downloads/downloads.properties
@@ -62,25 +62,18 @@ shortTimeLeftDays=%1$Sd
 # that we use a wider space after the separator when it is followed by a number,
 # just to avoid visually confusing it with with a minus sign with some fonts.
 # If you use a different separator, this might not be necessary.  However, there
 # is usually no need to change the separator or the order of the substitutions,
 # even for right-to-left languages, unless the defaults are not suitable.
 statusSeparator=%1$S \u2014 %2$S
 statusSeparatorBeforeNumber=%1$S \u2014  %2$S
 
-# LOCALIZATION NOTE (showMoreDownloads):
-# This string is shown in the Downloads Panel when there are more active
-# downloads than can fit in the available space.  The phrase should be read as
-# "Show N more of my recent downloads".  Use a semi-colon list of plural forms.
-# See: http://developer.mozilla.org/en/Localization_and_Plurals
-showMoreDownloads=Show 1 More Recent Download;Show %1$S More Recent Downloads
-# LOCALIZATION NOTE (showAllDownloads):
-# This string is shown in place of showMoreDownloads when all the downloads fit
-# in the available space, or when there are no downloads in the panel at all.
-showAllDownloads=Show All Downloads
-# LOCALIZATION NOTE (showDownloadsAccessKey):
-# This access key applies to both showMoreDownloads and showAllDownloads.
-showDownloadsAccessKey=S
-
 fileExecutableSecurityWarning="%S" is an executable file. Executable files may contain viruses or other malicious code that could harm your computer. Use caution when opening this file. Are you sure you want to launch "%S"?
 fileExecutableSecurityWarningTitle=Open Executable File?
 fileExecutableSecurityWarningDontAsk=Don't ask me this again
+
+# LOCALIZATION NOTE (otherDownloads):
+# This is displayed in an item at the bottom of the Downloads Panel when
+# there are more downloads than can fit in the list in the panel. Use a
+# semi-colon list of plural forms.
+# See: http://developer.mozilla.org/en/Localization_and_Plurals
+otherDownloads=+%1$S other current download; +%1$S other current downloads
--- a/browser/themes/gnomestripe/downloads/downloads.css
+++ b/browser/themes/gnomestripe/downloads/downloads.css
@@ -19,40 +19,60 @@
 }
 
 #downloadsHistory {
   background: transparent;
   color: -moz-nativehyperlinktext;
   cursor: pointer;
 }
 
+#downloadsSummary,
 #downloadsPanel[hasdownloads] > #downloadsHistory {
   border-top: 1px solid ThreeDShadow;
   background-image: -moz-linear-gradient(hsla(0,0%,0%,.15), hsla(0,0%,0%,.08) 6px);
 }
 
 #downloadsHistory > .button-box {
   margin: 1em;
 }
 
 #downloadsHistory:-moz-focusring > .button-box {
   outline: 1px -moz-dialogtext dotted;
 }
 
-/*** List items ***/
+/*** Downloads Summary and List items ***/
+
+#downloadsSummary,
+richlistitem[type="download"] {
+  height: 6em;
+  -moz-padding-end: 0;
+  color: inherit;
+}
+
+#downloadsSummary {
+  padding: 8px 38px 8px 12px;
+  cursor: pointer;
+}
+
+#downloadsSummary > .downloadTypeIcon {
+  height: 32px;
+  width: 32px;
+  list-style-image: url("chrome://mozapps/skin/downloads/downloadIcon.png");
+}
+
+#downloadsSummaryDescription {
+  color: -moz-nativehyperlinktext;
+}
 
 richlistitem[type="download"] {
-  height: 6em;
   margin: 0;
   border-top: 1px solid hsla(0,0%,100%,.2);
   border-bottom: 1px solid hsla(0,0%,0%,.15);
   background: transparent;
   padding: 8px;
-  -moz-padding-end: 0;
-  color: inherit;
 }
 
 richlistitem[type="download"]:first-child {
   border-top: 1px solid transparent;
 }
 
 richlistitem[type="download"]:last-child {
   border-bottom: 1px solid transparent;
--- a/browser/themes/pinstripe/downloads/downloads.css
+++ b/browser/themes/pinstripe/downloads/downloads.css
@@ -26,16 +26,17 @@
   cursor: pointer;
 }
 
 #downloadsPanel:not([hasdownloads]) > #downloadsHistory {
   border-top-left-radius: 6px;
   border-top-right-radius: 6px;
 }
 
+#downloadsSummary,
 #downloadsPanel[hasdownloads] > #downloadsHistory {
   background: #e5e5e5;
   border-top: 1px solid hsla(0,0%,0%,.1);
   box-shadow: 0 -1px hsla(0,0%,100%,.5) inset, 0 1px 1px hsla(0,0%,0%,.03) inset;
 }
 
 #downloadsHistory > .button-box {
   color: #808080;
@@ -48,27 +49,44 @@
   border-top-right-radius: 6px;
 }
 
 #downloadsPanel:not([hasdownloads]) > #downloadsHistory:-moz-focusring > .button-box {
   border-bottom-left-radius: 6px;
   border-bottom-right-radius: 6px;
 }
 
-/*** List items ***/
+/*** Downloads Summary and List items ***/
+
+#downloadsSummary,
+richlistitem[type="download"] {
+  height: 7em;
+  -moz-padding-end: 0;
+  color: inherit;
+}
+
+#downloadsSummary {
+  padding: 8px 38px 8px 12px;
+  cursor: pointer;
+}
+
+#downloadsSummary > .downloadTypeIcon {
+  list-style-image: url("chrome://mozapps/skin/downloads/downloadIcon.png");
+}
+
+#downloadsSummaryDescription {
+  color: -moz-nativehyperlinktext;
+}
 
 richlistitem[type="download"] {
-  height: 7em;
   margin: 0;
   border-top: 1px solid hsla(0,0%,100%,.07);
   border-bottom: 1px solid hsla(0,0%,0%,.2);
   background: transparent;
   padding: 8px;
-  -moz-padding-end: 0;
-  color: inherit;
 }
 
 richlistitem[type="download"]:first-child {
   border-top: 1px solid transparent;
 }
 
 richlistitem[type="download"]:last-child {
   border-bottom: 1px solid transparent;
--- a/browser/themes/winstripe/downloads/downloads.css
+++ b/browser/themes/winstripe/downloads/downloads.css
@@ -20,33 +20,53 @@
   cursor: pointer;
 }
 
 #downloadsHistory > .button-box {
   margin: 1em;
 }
 
 @media (-moz-windows-default-theme) {
+  #downloadsSummary,
   #downloadsPanel[hasdownloads] > #downloadsHistory {
     background-color: hsla(216,45%,88%,.98);
-    box-shadow: 0px 1px 2px rgb(204,214,234) inset;  
+    box-shadow: 0px 1px 2px rgb(204,214,234) inset;
   }
 }
 
-/*** List items ***/
+/*** Downloads Summary and List items ***/
+
+#downloadsSummary,
+richlistitem[type="download"] {
+  height: 7em;
+  -moz-padding-end: 0;
+  color: inherit;
+}
+
+#downloadsSummary {
+  padding: 8px 38px 8px 12px;
+  cursor: pointer;
+}
+
+#downloadsSummary > .downloadTypeIcon {
+  height: 24px;
+  width: 24px;
+  list-style-image: url("chrome://mozapps/skin/downloads/downloadIcon.png");
+}
+
+#downloadsSummaryDescription {
+  color: -moz-nativehyperlinktext;
+}
 
 richlistitem[type="download"] {
-  height: 7em;
   margin: 0;
   border-top: 1px solid hsla(0,0%,100%,.3);
   border-bottom: 1px solid hsla(220,18%,51%,.25);
   background: transparent;
   padding: 8px;
-  -moz-padding-end: 0;
-  color: inherit;
 }
 
 richlistitem[type="download"]:first-child {
   border-top: 1px solid transparent;
 }
 
 @media (-moz-windows-default-theme) {
   richlistitem[type="download"]:last-child {