Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Proposal] Allow seeking through seekTo before the HTMLMediaElement is ready #1607

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions demo/scripts/controllers/ProgressBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import ToolTip from "../components/ToolTip";
import VideoThumbnail from "../components/VideoThumbnail";
import useModuleState from "../lib/useModuleState";
import type { IPlayerModule } from "../modules/player/index";
import isNullOrUndefined from "../../../src/utils/is_null_or_undefined";

function ProgressBar({
player,
Expand All @@ -13,10 +14,10 @@ function ProgressBar({
player: IPlayerModule;
enableVideoThumbnails: boolean;
onSeek: () => void;
}): React.JSX.Element {
}): React.JSX.Element | null {
const bufferGap = useModuleState(player, "bufferGap");
const currentTime = useModuleState(player, "currentTime");
const isContentLoaded = useModuleState(player, "isContentLoaded");
const isStopped = useModuleState(player, "isStopped");
const isLive = useModuleState(player, "isLive");
const minimumPosition = useModuleState(player, "minimumPosition");
const livePosition = useModuleState(player, "livePosition");
Expand Down Expand Up @@ -111,19 +112,15 @@ function ProgressBar({
[player, showTimeIndicator, showThumbnail],
);

if (isStopped) {
return null;
}

const toolTipOffset =
wrapperElementRef.current !== null
? wrapperElementRef.current.getBoundingClientRect().left
: 0;

if (!isContentLoaded) {
return (
<div className="progress-bar-parent" ref={wrapperElementRef}>
<div className="progress-bar-wrapper" />
</div>
);
}

let thumbnailElement: React.JSX.Element | null = null;
if (thumbnailIsVisible) {
const xThumbnailPosition = tipPosition - toolTipOffset;
Expand All @@ -134,6 +131,7 @@ function ProgressBar({
}
}

const progressBarMaximum = livePosition ?? maximumPosition;
return (
<div className="progress-bar-parent" ref={wrapperElementRef}>
{timeIndicatorVisible ? (
Expand All @@ -145,14 +143,16 @@ function ProgressBar({
/>
) : null}
{thumbnailElement}
{currentTime === undefined ? null : (
{currentTime === undefined ||
isNullOrUndefined(minimumPosition) ||
isNullOrUndefined(progressBarMaximum) ? null : (
<ProgressbarComponent
seek={seek}
onMouseOut={hideToolTips}
onMouseMove={onMouseMove}
position={currentTime}
minimumPosition={minimumPosition}
maximumPosition={livePosition ?? maximumPosition}
maximumPosition={progressBarMaximum}
bufferGap={bufferGap}
/>
)}
Expand Down
2 changes: 1 addition & 1 deletion demo/scripts/modules/player/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ function linkPlayerEventsToState(

switch (playerState) {
case "LOADING":
startPositionUpdates();
stateUpdates.useWorker = player.getCurrentModeInformation()?.useWorker === true;
break;
case "ENDED":
Expand All @@ -243,7 +244,6 @@ function linkPlayerEventsToState(
stateUpdates.isPaused = false;
break;
case "LOADED":
startPositionUpdates();
stateUpdates.isPaused = true;
stateUpdates.isLive = player.isLive();
break;
Expand Down
4 changes: 2 additions & 2 deletions demo/styles/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ header .right {
cursor: pointer;
display: flex;
position: relative;
background-color: #000;
background-color: #333333;
width: 100%;
margin: auto;
transition: all 0.2s ease;
Expand All @@ -387,7 +387,7 @@ header .right {
.progress-bar-buffered {
position: absolute;
height: 100%;
background-color: #333;
background-color: #686868;
z-index: 2;
transition-duration: 0.3s;
}
Expand Down
2 changes: 1 addition & 1 deletion doc/api/Basic_Methods/seekTo.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The argument can be an object with a single `Number` property, either:

- `relative`: seek relatively to the current position

- `position`: seek to the given absolute position (equivalent to
- `position`: seek to the given absolute position (equivalent to what you would give to
`player.getVideoElement().currentTime = newPosition`)

- `wallClockTime`: seek to the given wallClock position, as returned by
Expand Down
3 changes: 1 addition & 2 deletions doc/api/Player_States.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,7 @@ limitation.
While this state is active, multiple player API are unavailable:

- you cannot play or pause
- you cannot seek
- you cannot obtain the last playing position or the content duration
- you cannot obtain the previous playing position or the content duration

This is why we sometime recommend to manage this state as if it was the `LOADING` state
(where those APIs - and other - are also not available).
Expand Down
53 changes: 30 additions & 23 deletions src/core/main/common/content_time_boundaries_observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type { IReadOnlyPlaybackObserver } from "../../../playback_observer";
import type { IPlayerError } from "../../../public_types";
import EventEmitter from "../../../utils/event_emitter";
import isNullOrUndefined from "../../../utils/is_null_or_undefined";
import queueMicrotask from "../../../utils/queue_microtask";
import SortedList from "../../../utils/sorted_list";
import TaskCanceller from "../../../utils/task_canceller";

Expand Down Expand Up @@ -94,29 +95,35 @@ export default class ContentTimeBoundariesObserver extends EventEmitter<IContent
this._maximumPositionCalculator = maximumPositionCalculator;

const cancelSignal = this._canceller.signal;
playbackObserver.listen(
({ position }) => {
const wantedPosition = position.getWanted();
if (wantedPosition < manifest.getMinimumSafePosition()) {
const warning = new MediaError(
"MEDIA_TIME_BEFORE_MANIFEST",
"The current position is behind the " +
"earliest time announced in the Manifest.",
);
this.trigger("warning", warning);
} else if (
wantedPosition > maximumPositionCalculator.getMaximumAvailablePosition()
) {
const warning = new MediaError(
"MEDIA_TIME_AFTER_MANIFEST",
"The current position is after the latest " +
"time announced in the Manifest.",
);
this.trigger("warning", warning);
}
},
{ includeLastObservation: true, clearSignal: cancelSignal },
);

// As the following code may send events synchronously, which would not be
// catchable as a caller could not have called `addEventListener` yet,
// we schedule it in a micro-task
queueMicrotask(() => {
playbackObserver.listen(
({ position }) => {
const wantedPosition = position.getWanted();
if (wantedPosition < manifest.getMinimumSafePosition()) {
const warning = new MediaError(
"MEDIA_TIME_BEFORE_MANIFEST",
"The current position is behind the " +
"earliest time announced in the Manifest.",
);
this.trigger("warning", warning);
} else if (
wantedPosition > maximumPositionCalculator.getMaximumAvailablePosition()
) {
const warning = new MediaError(
"MEDIA_TIME_AFTER_MANIFEST",
"The current position is after the latest " +
"time announced in the Manifest.",
);
this.trigger("warning", warning);
}
},
{ includeLastObservation: true, clearSignal: cancelSignal },
);
});

manifest.addEventListener(
"manifestUpdate",
Expand Down
41 changes: 25 additions & 16 deletions src/main_thread/api/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1014,10 +1014,27 @@ class Player extends EventEmitter<IPublicAPIEvent> {
});
}

/** Global "playback observer" which will emit playback conditions */
const playbackObserver = new MediaElementPlaybackObserver(videoElement, {
withMediaSource: !isDirectFile,
lowLatencyMode,
});

/*
* We want to block seeking operations until we know the media element is
* ready for it.
*/
playbackObserver.blockSeeking();

currentContentCanceller.signal.register(() => {
playbackObserver.stop();
});

/** Future `this._priv_contentInfos` related to this content. */
const contentInfos: IPublicApiContentInfos = {
contentId: generateContentId(),
originalUrl: url,
playbackObserver,
currentContentCanceller,
defaultAudioTrackSwitchingMode,
initializer,
Expand Down Expand Up @@ -1108,16 +1125,6 @@ class Player extends EventEmitter<IPublicAPIEvent> {
// content.
this.stop();

/** Global "playback observer" which will emit playback conditions */
const playbackObserver = new MediaElementPlaybackObserver(videoElement, {
withMediaSource: !isDirectFile,
lowLatencyMode,
});

currentContentCanceller.signal.register(() => {
playbackObserver.stop();
});

// Update the RxPlayer's state at the right events
const playerStateRef = constructPlayerStateReference(
initializer,
Expand Down Expand Up @@ -1677,10 +1684,6 @@ class Player extends EventEmitter<IPublicAPIEvent> {
}

const { isDirectFile, manifest } = this._priv_contentInfos;
if (!isDirectFile && manifest === null) {
throw new Error("player: the content did not load yet");
}

let positionWanted: number | undefined;

if (typeof time === "number") {
Expand All @@ -1700,7 +1703,11 @@ class Player extends EventEmitter<IPublicAPIEvent> {
} else if (!isNullOrUndefined(timeObj.wallClockTime)) {
if (manifest !== null) {
positionWanted = timeObj.wallClockTime - (manifest.availabilityStartTime ?? 0);
} else if (isDirectFile && this.videoElement !== null) {
} else if (!isDirectFile) {
throw new Error(
"Cannot seek: wallClockTime asked but Manifest not yet loaded.",
);
} else if (this.videoElement !== null) {
const startDate = getStartDate(this.videoElement);
if (startDate !== undefined) {
positionWanted = timeObj.wallClockTime - startDate;
Expand All @@ -1722,7 +1729,7 @@ class Player extends EventEmitter<IPublicAPIEvent> {
throw new Error("invalid time given");
}
log.info("API: API Seek to", positionWanted);
this.videoElement.currentTime = positionWanted;
this._priv_contentInfos.playbackObserver.setCurrentTime(positionWanted, false);
return positionWanted;
}

Expand Down Expand Up @@ -3376,6 +3383,8 @@ interface IPublicApiContentInfos {
originalUrl: string | undefined;
/** `ContentInitializer` used to load the content. */
initializer: ContentInitializer;
/** interface emitting regularly playback observations. */
playbackObserver: MediaElementPlaybackObserver;
/** TaskCanceller triggered when it's time to stop the current content. */
currentContentCanceller: TaskCanceller;
/** The default behavior to adopt when switching the audio track. */
Expand Down
8 changes: 7 additions & 1 deletion src/main_thread/init/utils/get_initial_time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,19 @@ export default function getInitialTime(
const min = getMinimumSafePosition(manifest);
const max = getMaximumSafePosition(manifest);
if (!isNullOrUndefined(startAt.position)) {
log.debug("Init: using startAt.minimumPosition");
log.debug("Init: using startAt.position");
if (manifest.isDynamic) {
return startAt.position;
}
return Math.max(Math.min(startAt.position, max), min);
} else if (!isNullOrUndefined(startAt.wallClockTime)) {
log.debug("Init: using startAt.wallClockTime");
const ast =
manifest.availabilityStartTime === undefined ? 0 : manifest.availabilityStartTime;
const position = startAt.wallClockTime - ast;
if (manifest.isDynamic) {
return position;
}
return Math.max(Math.min(position, max), min);
} else if (!isNullOrUndefined(startAt.fromFirstPosition)) {
log.debug("Init: using startAt.fromFirstPosition");
Expand Down
18 changes: 17 additions & 1 deletion src/main_thread/init/utils/initial_seek_and_play.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,20 @@ export default function performInitialSeekAndPlay(
let hasAskedForInitialSeek = false;

const performInitialSeek = (initialSeekTime: number) => {
playbackObserver.setCurrentTime(initialSeekTime);
const pendingSeek = playbackObserver.getPendingSeekInformation();

/*
* NOTE: The user might have asked for a seek before the media element
* was ready, in which case we want the seek to be at the user's wanted
* position instead.
* If multiple internal seeks were asked however, we want to keep the
* last one.
*/
if (pendingSeek === null || pendingSeek.isInternal) {
playbackObserver.setCurrentTime(initialSeekTime);
}
hasAskedForInitialSeek = true;
playbackObserver.unblockSeeking();
};

// `startTime` defined as a function might depend on metadata to make its
Expand All @@ -109,6 +121,8 @@ export default function performInitialSeekAndPlay(
typeof startTime === "number" ? startTime : startTime();
if (initiallySeekedTime !== 0 && initiallySeekedTime !== undefined) {
performInitialSeek(initiallySeekedTime);
} else {
playbackObserver.unblockSeeking();
}
waitForSeekable();
} else {
Expand Down Expand Up @@ -141,6 +155,8 @@ export default function performInitialSeekAndPlay(
performInitialSeek(initiallySeekedTime);
}, 0);
}
} else {
playbackObserver.unblockSeeking();
}
waitForSeekable();
}
Expand Down
Loading
Loading