Repository: shaka-project/shaka-player (JavaScript)

Issue:
Ability to pass in a Date object for playback start time
**Have you read the [FAQ](https://bit.ly/ShakaFAQ) and checked for duplicate open issues?**
Yes

**Is your feature request related to a problem? Please describe.**
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
We want to be able to switch streams and have the user resume from the same moment that they left. We also want to minimize wait time on load so that the loading will start at this provided Date.

**Describe the solution you'd like**
<!-- A clear and concise description of what you want to happen. -->
We want to be able to provide a Date object for beginning playback. This is similar to the `startTime` option on `manifest` config and the `startTime` argument on the `load()` method. The benefit is that this should allow for resuming from the same location even if the streams' encoders aren't synced together, as long as they have the correct time information (PDT for HLS and the relevant ones on dash that I'm currently blanking on the name).

Option A: 
Add new `manifest.startDate` option, as a matching pair to `startTime`, which takes in a JS Date object. If both are provided, use `startDate`, though, can also throw an error in this case.
Add `Player#updateStartDate()`. Calling both `updateStartDate` and `updateStartTime` can probably accept the latest one provided.
`load()` and `preload()` would need a new argument, but then it's a bit confusing with both a `startTime` and a `startDate` arg.

Option B:
Update `startTime` to accept either a `number` or a `Date` object. The behavior of would then depend on the usage.


**Describe alternatives you've considered**
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
`updateStartTime()` works great to update when playback should at `manifestparsed`. However, to properly translate a Date into a time, we need `player.getPlayheadTimeAsDate()` to be available and that isn't available until `loaded`. At which point, shaka has already started loading, and we'd need to seek to the new position. For example:
```js
player.updateStartTime((startDate.getTime()-player.getPlayheadTimeAsDate().getTime())/1000)
```

**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->
A short thread on #shaka-player in video dev: https://video-dev.slack.com/archives/C01QRAFHLQK/p1742581046067759.

Additionally, this is similar to MPD anchors (https://github.com/shaka-project/shaka-player/issues/1038) but unlike MPD anchors it should be generic enough to work with either DASH or HLS assuming that the manifests have the required information.

**Are you planning to send a PR to add it?**
Once we iron out the API, we plan to work this feature.


Discussion: I’d prefer option B
FYI, dash.js provides a way to specify start time as string with `posix` prefix.
I'm going to assume that they use the posix prefix because of mpd anchors. I think a Date object is the most JSy, but a string could be converted internally if we felt the need.



Fix (patch):
diff --git a/lib/media/playhead.js b/lib/media/playhead.js
index 9998414bbc..53ff6eab88 100644
--- a/lib/media/playhead.js
+++ b/lib/media/playhead.js
@@ -45,7 +45,7 @@ shaka.media.Playhead = class {
    * Set the start time. If the content has already started playback, this will
    * be ignored.
    *
-   * @param {number} startTime
+   * @param {number|Date} startTime
    */
   setStartTime(startTime) {}
 
@@ -93,7 +93,7 @@ shaka.media.SrcEqualsPlayhead = class {
     this.mediaElement_ = mediaElement;
     /** @private {boolean} */
     this.started_ = false;
-    /** @private {?number} */
+    /** @private {?number|Date} */
     this.startTime_ = null;
 
     /** @private {shaka.util.EventManager} */
@@ -107,18 +107,35 @@ shaka.media.SrcEqualsPlayhead = class {
         'Playhead should not be released before calling ready()',
     );
 
-    // We listen for the loaded-data-event so that we know when we can
+    // We listen for the canplay event so that we know when we can
     // interact with |currentTime|.
-    const onLoaded = () => {
+    // We were using loadeddata before, but if we set time on that event,
+    // browser may adjust it on its own during live playback.
+    const onCanPlay = () => {
       if (this.startTime_ == null ||
           (this.startTime_ == 0 && this.mediaElement_.duration != Infinity)) {
         this.started_ = true;
       } else {
         const currentTime = this.mediaElement_.currentTime;
-        let newTime = this.startTime_;
+        let newTime = null;
+        if (typeof this.startTime_ === 'number') {
+          newTime = this.startTime_;
+        } else if (this.startTime_ instanceof Date) {
+          const programStartTime = this.getProgramStartTime_();
+          if (programStartTime !== null) {
+            newTime = (this.startTime_.getTime() / 1000.0) - programStartTime;
+            newTime = this.clampTime_(newTime);
+          }
+        }
+
+        if (newTime == null) {
+          this.started_ = true;
+          return;
+        }
+
         // Using the currentTime allows using a negative number in Live HLS
-        if (this.startTime_ < 0) {
-          newTime = Math.max(0, currentTime + this.startTime_);
+        if (newTime < 0) {
+          newTime = Math.max(0, currentTime + newTime);
         }
         if (currentTime != newTime) {
           // Startup is complete only when the video element acknowledges the
@@ -134,9 +151,9 @@ shaka.media.SrcEqualsPlayhead = class {
     };
 
     shaka.util.MediaReadyState.waitForReadyState(this.mediaElement_,
-        HTMLMediaElement.HAVE_CURRENT_DATA,
+        HTMLMediaElement.HAVE_FUTURE_DATA,
         this.eventManager_, () => {
-          onLoaded();
+          onCanPlay();
         });
   }
 
@@ -161,9 +178,13 @@ shaka.media.SrcEqualsPlayhead = class {
   getTime() {
     // If we have not started playback yet, return the start time. However once
     // we start playback we assume that we can always return the current time.
-    const time = this.started_ ?
+    let time = this.started_ ?
                  this.mediaElement_.currentTime :
                  this.startTime_;
+    if (time instanceof Date) {
+      time = (time.getTime() / 1000.0) - (this.getProgramStartTime_() || 0);
+      time = this.clampTime_(time);
+    }
 
     // In the case that we have not started playback, but the start time was
     // never set, we don't know what the start time should be. To ensure we
@@ -183,6 +204,36 @@ shaka.media.SrcEqualsPlayhead = class {
 
   /** @override */
   notifyOfBufferingChange() {}
+
+  /**
+   * @return {?number} program start time in seconds.
+   * @private
+   */
+  getProgramStartTime_() {
+    if (this.mediaElement_.getStartDate) {
+      const startDate = this.mediaElement_.getStartDate();
+      const startTime = startDate.getTime();
+      if (!isNaN(startTime)) {
+        return startTime / 1000.0;
+      }
+    }
+    return null;
+  }
+
+
+  /**
+   * @param {number} time
+   * @return {number}
+   * @private
+   */
+  clampTime_(time) {
+    const seekable = this.mediaElement_.seekable;
+    if (seekable.length > 0) {
+      time = Math.max(seekable.start(0), time);
+      time = Math.min(seekable.end(seekable.length - 1), time);
+    }
+    return time;
+  }
 };
 
 
@@ -202,7 +253,7 @@ shaka.media.MediaSourcePlayhead = class {
    * @param {!HTMLMediaElement} mediaElement
    * @param {shaka.extern.Manifest} manifest
    * @param {shaka.extern.StreamingConfiguration} config
-   * @param {?number} startTime
+   * @param {?number|Date} startTime
    *     The playhead's initial position in seconds. If null, defaults to the
    *     start of the presentation for VOD and the live-edge for live.
    * @param {function()} onSeek
@@ -291,7 +342,7 @@ shaka.media.MediaSourcePlayhead = class {
 
   /** @override */
   setStartTime(startTime) {
-    this.videoWrapper_.setTime(startTime);
+    this.videoWrapper_.setTime(this.getStartTime_(startTime));
   }
 
   /** @override */
@@ -327,7 +378,7 @@ shaka.media.MediaSourcePlayhead = class {
   /**
    * Gets the playhead's initial position in seconds.
    *
-   * @param {?number} startTime
+   * @param {?number|Date} startTime
    * @return {number}
    * @private
    */
@@ -341,6 +392,13 @@ shaka.media.MediaSourcePlayhead = class {
         // Otherwise, start near the live-edge.
         startTime = this.timeline_.getSeekRangeEnd();
       }
+    } else if (startTime instanceof Date) {
+      const presentationStartTime =
+          this.timeline_.getInitialProgramDateTime() ||
+          this.timeline_.getPresentationStartTime();
+      goog.asserts.assert(presentationStartTime != null,
+          'Presentation start time should not be null!');
+      startTime = (startTime.getTime() / 1000.0) - presentationStartTime;
     } else if (startTime < 0) {
       // For live streams, if the startTime is negative, start from a certain
       // offset time from the live edge.  If the offset from the live edge is
@@ -349,7 +407,8 @@ shaka.media.MediaSourcePlayhead = class {
       startTime = this.timeline_.getSeekRangeEnd() + startTime;
     }
 
-    return this.clampSeekToDuration_(this.clampTime_(startTime));
+    return this.clampSeekToDuration_(
+        this.clampTime_(/** @type {number} */(startTime)));
   }
 
   /** @override */
diff --git a/lib/media/preload_manager.js b/lib/media/preload_manager.js
index 95fce62813..c42072243e 100644
--- a/lib/media/preload_manager.js
+++ b/lib/media/preload_manager.js
@@ -37,7 +37,7 @@ shaka.media.PreloadManager = class extends shaka.util.FakeEventTarget {
   /**
    * @param {string} assetUri
    * @param {?string} mimeType
-   * @param {?number} startTime
+   * @param {?number|Date} startTime
    * @param {*} playerInterface
    */
   constructor(assetUri, mimeType, startTime, playerInterface) {
@@ -59,7 +59,7 @@ shaka.media.PreloadManager = class extends shaka.util.FakeEventTarget {
     /** @private {!shaka.net.NetworkingEngine} */
     this.networkingEngine_ = typedPlayerInterface.networkingEngine;
 
-    /** @private {?number} */
+    /** @private {?number|Date} */
     this.startTime_ = startTime;
 
     /** @private {?shaka.media.AdaptationSetCriteria} */
@@ -214,11 +214,15 @@ shaka.media.PreloadManager = class extends shaka.util.FakeEventTarget {
   /** @param {number} offset */
   setOffsetToStartTime(offset) {
     if (this.startTime_ && offset) {
-      this.startTime_ += offset;
+      if (typeof this.startTime_ === 'number') {
+        this.startTime_ += offset;
+      } else {
+        this.startTime_.setTime(this.startTime_.getTime() + offset * 1000);
+      }
     }
   }
 
-  /** @return {?number} */
+  /** @return {?number|Date} */
   getStartTime() {
     return this.startTime_;
   }
@@ -785,7 +789,9 @@ shaka.media.PreloadManager = class extends shaka.util.FakeEventTarget {
     if (!stream.segmentIndex) {
       await stream.createSegmentIndex();
     }
-    const startTime = this.startTime_ || 0;
+    // Ignore if start time is a Date, as we do not prefetch segments for live
+    // anyway.
+    const startTime = typeof this.startTime_ === 'number' ? this.startTime_ : 0;
     const prefetchSegmentIterator =
         stream.segmentIndex.getIteratorForTime(startTime);
     let prefetchSegment = null;
diff --git a/lib/player.js b/lib/player.js
index a59f031719..752ce8f2a2 100644
--- a/lib/player.js
+++ b/lib/player.js
@@ -794,7 +794,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
     /** @private {?string} */
     this.mimeType_ = null;
 
-    /** @private {?number} */
+    /** @private {?number|Date} */
     this.startTime_ = null;
 
     /** @private {boolean} */
@@ -1612,7 +1612,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
    * event handler to update the start position based on information in the
    * manifest.
    *
-   * @param {number} startTime
+   * @param {number|Date} startTime
    * @export
    */
   updateStartTime(startTime) {
@@ -1624,7 +1624,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
    * If another stream was already playing, first unloads that stream.
    *
    * @param {string|shaka.media.PreloadManager} assetUriOrPreloader
-   * @param {?number=} startTime
+   * @param {?number|Date=} startTime
    *    When <code>startTime</code> is <code>null</code> or
    *    <code>undefined</code>, playback will start at the default start time (0
    *    for VOD and liveEdge for LIVE).
@@ -2032,7 +2032,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
    * asset must be played with src=, which cannot be preloaded.
    *
    * @param {string} assetUri
-   * @param {?number=} startTime
+   * @param {?number|Date=} startTime
    *    When <code>startTime</code> is <code>null</code> or
    *    <code>undefined</code>, playback will start at the default start time (0
    *    for VOD and liveEdge for LIVE).
@@ -2071,7 +2071,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
 
   /**
    * @param {string} assetUri
-   * @param {?number} startTime
+   * @param {?number|Date} startTime
    * @param {?string=} mimeType
    * @param {boolean=} standardLoad
    * @return {!Promise<?shaka.media.PreloadManager>}
@@ -2129,7 +2129,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
 
   /**
    * @param {string} assetUri
-   * @param {?number} startTime
+   * @param {?number|Date} startTime
    * @param {?string} mimeType
    * @param {boolean=} allowPrefetch
    * @param {boolean=} disableVideo
@@ -2801,7 +2801,22 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
       // align to a segment boundary.
       if (this.config_.streaming.startAtSegmentBoundary) {
         const timeline = this.manifest_.presentationTimeline;
-        let initialTime = this.startTime_ || this.video_.currentTime;
+        let initialTime;
+        if (this.startTime_ instanceof Date) {
+          const presentationStartTime = timeline.getInitialProgramDateTime() ||
+              timeline.getPresentationStartTime();
+          goog.asserts.assert(presentationStartTime != null,
+              'Presentation start time should not be null!');
+          const time = (this.startTime_.getTime() / 1000.0) -
+              presentationStartTime;
+          if (time != null) {
+            initialTime = time;
+          }
+        }
+        if (initialTime == null) {
+          initialTime = typeof this.startTime_ === 'number' ? this.startTime_ :
+              this.video_.currentTime;
+        }
         if (this.startTime_ == null && this.manifest_.startTime) {
           initialTime = this.manifest_.startTime;
         }
@@ -3736,7 +3751,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
    * Creates a new instance of Playhead.  This can be replaced by tests to
    * create fake instances instead.
    *
-   * @param {?number} startTime
+   * @param {?number|Date} startTime
    * @return {!shaka.media.Playhead}
    */
   createPlayhead(startTime) {
@@ -3755,7 +3770,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
    * Create the observers for MSE playback. These observers are responsible for
    * notifying the app and player of specific events during MSE playback.
    *
-   * @param {number} startTime
+   * @param {number|Date} startTime
    * @return {!shaka.media.PlayheadObserverManager}
    * @private
    */
@@ -3768,7 +3783,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
         'Must have emsg region timeline');
     goog.asserts.assert(this.video_, 'Must have video element');
 
-    const startsPastZero = this.isLive() || startTime > 0;
+    const startsPastZero = this.isLive() ||
+        (typeof startTime === 'number' && startTime > 0);
 
     // Create the region observer. This will allow us to notify the app when we
     // move in and out of timeline regions.
@@ -3846,7 +3862,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
    * responsible for notifying the app and player of specific events during src
    * equals playback.
    *
-   * @param {number} startTime
+   * @param {number|!Date} startTime
    * @return {!shaka.media.PlayheadObserverManager}
    * @private
    */
@@ -3855,7 +3871,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
         'Must have metadata region timeline');
     goog.asserts.assert(this.video_, 'Must have video element');
 
-    const startsPastZero = startTime > 0;
+    const startsPastZero = startTime instanceof Date || startTime > 0;
 
     /**
      * @type {!shaka.media.RegionObserver<
@@ -6243,6 +6259,10 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
       // would start at the live edge, but we don't have that yet, so return
       // the current date & time.
       return new Date();
+    } else if (this.startTime_ instanceof Date) {
+      // A specific start time as a Date has been requested.  Return it without
+      // any modification.
+      return this.startTime_;
     } else {
       // A specific start time has been requested.  This is what Playhead will
       // use once it is created.