Skip to content

[WIP] Preload architecture proposal#1646

Open
peaBerberian wants to merge 5 commits intodevfrom
feat/preload4
Open

[WIP] Preload architecture proposal#1646
peaBerberian wants to merge 5 commits intodevfrom
feat/preload4

Conversation

@peaBerberian
Copy link
Collaborator

@peaBerberian peaBerberian commented Feb 10, 2025

⚠️ NOTE: This is purely a proof-of-concept for an attempt of a preload API in the RxPlayer.

Based on #1645

After first drafts more than a year ago, I at last began to see a path in how it could be implemented without losing any feature and without sacrificing the RxPlayer's code too much, though this PR is still just a POC and a work-in-progress.
The real final implementation would probably be separated into multiple PRs.

tl;dr

This PR shows a possible (and functional) RxPlayer architecture for a performant preload API, which allows to pre-buffer a content in-memory while potentially another content is playing on the media element. Potential usages are to make transitions between ad breaks/content (pre-roll, mid-roll, post-roll) and/or sequences of multiple contents (e.g. multiple episodes of a TV serie) smoother.

Here's what it looks like (I quickly added another button on the demo page to preload a content):

12.mp4

Video: We begin by loading a content (after simulating a low-ish network bandwidth). We see that it takes some time before that content can play. We then choose to pre-load a content - we can see segments being loaded but not yet pushed / played. Then we start the preload. On my computer - this startPreload step is almost instantaneous. In some embedded devices, it has been observed to still take some time.

Overview

This PR describes a proposal architecture to allow "content preloading", which is an advanced feature in some MSE players where a future content is loaded before being actually played to lower the time it will take once the user want to play that content.

It can be used as a mechanism to improve loading performance when we're very confident that the user will play a content yet we don't want to play it on the media element yet.
It may also be used to accelerate the transition between contents that will be played sequentially on the same media element (advertisement cuts, switching between episodes of a TV serie etc.).

As a supplementary goal, it may both be used explicitely by an application through an RxPlayer API or implicitely through an adaptive streaming technology mechanism that could profit from it (e.g. "interstitial" systems, MPD chaining etc.).

Preloading high level behavior

After playing with multiple strategies, I for now chose to implement "preloading" by loading that content's media segments directly in JS memory (instead of pushing them to the lower-level browser's buffers). When/if the application then wants to actually load the content, we will push all of that stored data on the actual browser's buffers linked to the HTMLMediaElement (which is itself linked to the current RxPlayer instance).

An alternative solution that was studied was to preload and load all contents on the same HTMLMediaElement, only at an offseted time for each preload. Though it could lead to better performance (i.e. a simple seek to switch content is generally much faster than initializing all MSE API and then feeding it data), I decided to abandon that solution for now because of fears of compatibility issues.

I also postpone some API calls, such as encryption-related ones, for when the content is actually played to increase device compatibility. This even though those encryption-related exchanges may themselves take time and even though they also can lead to multiple licence requests at once when the content is finally played.

RxPlayer API for preloading

Starting/Stopping a preload

An application can ask for a content to be preloaded through a preloadVideo API, which will return a contentId identifier.

That API can be called multiple times for various contents, to preload all them simultaneously, though it should be noted that there may be memory, performance, and bandwidth-related issues if pre-loading too much contents at once. This last point is for now considered as a problem left for the application, though we may provide improvements to help them with those points in the future as those are not subjects they're usually exposed to.

An application can then actually load a preloaded content at any time by calling the startPreload API, which will stop the previous loaded content if one, but none of the potential other preloads.

To stop preloading content, a stopPreload API was added. Also a clearPreload API was added to allow an application to stop all preloads it has started.

Track and quality selection

All track-related API (setAudioTrack, getAvailableVideoTracks etc.) and representations-related API (lockVideoRepresentation etc.) now also accept an optional contentId in which case they apply to the preload concerned.

Preload events

When preloading a content, an application might also want to subscribe to some of the same events than it does when loading a content. For example newAvailablePeriods to set the initial choosen tracks or error to be notified if the preload fails.

Because of this, I duplicated those concerned events with a preload- prefix (reusing the same one would have either conflicted with those or broke the API):

  • "preload-newAvailablePeriods"
  • "preload-brokenRepresentationsLock"
  • "preload-trackUpdate"
  • "preload-representationListUpdate"
  • "preload-error"
  • "preload-warning"

Those events also signal the contentId to signal which preload concerned (and the actual payload is communicated through a payload field).

Internal changes done for that new behavior

One of the key design strategy I followed for content preloading was that that rarely-used feature should not greatly decrease the readability of the RxPlayer's architecture.

With that in mind from the perspective of the RxPlayer's inner code, the main differences between a content that is pre-loading and a content that is actually loading is that:

  1. the pre-loaded content doesn't have an HTMLMediaElement (e.g. the <video> tag) yet, though it will have one if we ever play it.
  2. its MediaSource (the main object in the MSE group of API, which allows to feed media data programatically to an HTMLMediaElement) is not yet "ready" to play contents,
  3. and as such SourceBuffers (MSE media buffers) cannot yet be added.

Working around not having the HTMLMediaElement when pre-loading

As the HTMLMediaElement is only playing the content when actually loading, not pre-loading, I decided the following modifications:

  1. For a few modules, they now can function with AND without an HTMLMediaElement yet. In the cases where an HTMLMediaElement hasn't been provided to it , they behave in a not-yet-ready state as if an HTMLMediaElement will be provided to it in the future coincidentally with actual playback.

    For example, ITextDisplayer implementations will store subtitles but not display them on screen synchronized with the media element until... a media element has actually been provided through its attachMediaElement method (which may be called when/if the application finally decide to actually load that preload).

    Likewise, a MediaElementPlaybackObserver will just advertise about the default initial position until an actual HTMLMediaElement has been provided here also though an attachMediaElement method.

  2. For most other cases, I removed direct usage of the HTMLMediaElement to prefer relying on the MediaElementPlaybackObserver instead, which is already intrinsically linked to the HTMLMediaElement.

    I then added to the MediaElementPlaybackObserver a method called onMediaElementAttachment which allows to register a callback that will be called once the HTMLMediaElement has been attached to it (or called immediately and synchronously if it is already the case). This is what those other modules can rely on to declare logic relying on the media element.

Working around not having SourceBuffer objects

SourceBuffer are very important here because preloading is all about loading in advance media segments and because under the RxPlayer's "normal" (i.e. "loading", not "preloading") behavior, media segments were immediately pushed to their SourceBuffer after loading them (with the RxPlayer preferably not keeping an in-memory reference to that huge chunk of data).

To be more exact, the RxPlayer most often do not use SourceBuffer objects directly, it uses multiple abstractions to reach them :

  1. the SourceBufferInterface abstraction which provides a more sensible API (promise-based methods instead of the older-school event-based API SourceBuffer objects have, queue system abstracted instead of exposed to the application) yet which stays roughly at the same functional level than a SourceBuffer: it serves the same purpose - adding and removing media data from lower-levels buffers - and do no more.

  2. At a higher level, SourceBufferInterface are then used by the AudioVideoSegmentSink abstraction.

    They implement multiple features to facilitate the usage of those buffers: an inventory of the currently-pushed segments' metadata, it has an history system of recent operations, pending operations that have not yet finished can be inspected and other higher-level niceties.

    It is then AudioVideoSegmentSink instances that are used by most of the RxPlayer's modules.

Making the AudioVideoSegmentSink work without a SourceBufferInterface

The solution I proposed here is to make the AudioVideoSegmentSink temporarily work without a SourceBufferInterface and in that scenario to instead store all asked operations (segment pushing, data remove etc.) in-memory.

From outside code, the corresponding operation's Promises will just be left opened, indicating that those operations have not yet been performed succesfully yet.

When it is finally possible to obtain a SourceBuffer / SourceBufferInterface, the AudioVideoSegmentSink's linkToMediaSource method is called, which has the effect to directly communicate all operations pending in memory to that SourceBufferInterface. It is at that point that those operations' Promises are bound to the actual SourceBuffer operation.

All that happens completely transparently so that other modules continue to interact with the AudioVideoSegmentSink without even knowing that it is currently in the process of "transferring" data from memory to a SourceBuffer.

Working around not having a ready MediaSource

Under normal ("loading") conditions, we create a MediaSource object almost immediately after the loadVideo API is called, attach it to the HTMLMediaElement and then wait for that MediaSource's readyState property to switch to "open" and for the MediaKeys to be linked to the HTMLMediaElement. All this so we can then load segments on the SourceBuffer and start playing.

We do not want to link a MediaSource nor a MediaKeys to the HTMLMediaElement when preloading though.

To work-around this, the Core part of the RxPlayer now separates the concept of being "ready" to fetch+push segments and the concept of having the media element actually available for playback: the Core will start by creating a SegmentSinksStore without a MediaSource.

Note that for code simplification purposes, this for now happen for all contents - not just the preloaded ones. We thus always begin by creating AudioVideoSegmentSink storing segments in memory before actually switching to the real deal when ready.

Generally when actually "loading" (and not "preloading"), segment fetching should take longer than the MediaSource and MediaKeys attachment logic in which case there shouldn't really be an observable in-JS storing of segments, not that this would be a problem anyway. It might even end up improving performance on devices where either MediaSource, MediaKeys attachment or both took a long time.

This is implemented through a supplementary message from main_thread to core: MediaElementReady.

Memory concerns

Considering we already have memory-related concerns on many devices (especially some smart TV and set-top boxes), enabling a preload feature on them seems for now a little ambitious: instead of loading a single content and having trouble doing so, we may here not only load a content but also pre-load the future one at the same time.

This is something I had in mind when doing the implementation, and the end goal is to allow the preloading feature to be enabled even on those devices. The key factor we may want to consider here is how easy it will be to integrate that notion with the current preloading implementation. I saw multiple possible compatible ways (e.g. synchronizing options like maxVideoBufferSize so it also consider preloads could seem logical) but did not do anything in that sense yet to keep the initial implementation """"simple"""".

Can this work be reused for a content downloading feature

As I know there is some long term need for this, both at Canal+ and from outsiders, and as it may seem similar to a downloading concept (as when preloading, contents are also loaded for later), I thought that I had to add this chapter to this documentation.

The answer is: I don't think so.

For downloads, the solution found by other implementations we've seen have many key differences:

  • they favor the highest compatible media quality without network bandwidth considerations, where content preloading starts from the idea that playback will soon start and thus act like our regular playback logic on that point

  • for downloaded contents, playback will most often start much later, in a different browsing session and thus in a different RxPlayer instance. In contrast with "preloading" where preloading and actually playing are usually close in time to each other and are always on the same RxPlayer instance.

    The current preloading solution proposed is basically about handling a "not-yet-playing" mode, where the media element can then be "hot-swapped" (well, "hot-inserted") to enable actual playback on the current RxPlayer instance.

    This also means that all modules handling playback-related issues are in-place (and there's a LOT of them) because they might be imminently-needed. This is just added complexity in a context where the content will not be actually played in that same session.

Because of those, I actually think that having a separate code path/architecture for content downloading than for content loading and preloading would be easier to follow and maintain (though we know some people do not believe us on this :p!). Most advanced features worked-around here (SegmentSink inventories, HTMLMediaElement usage, PlaybackObserver etc.) are not needed at all for content downloading because they are linked to content playback.

The main logic of content downloading seem actually easier to implement than what we're doing here, the complexities we had at the time of our first attempts (at implementing content downloading) were mostly linked to device compatibility with some advanced features (e.g. available long-term storage API on some devices, persistent licence implementation) and all the necessary code around the inevitable downloading-specific issues (no space left on storage, async suppression of downloaded contents that may be interrupted by the user closing the page etc.).

Adding those orthogonal complexities linked to content downloading on top of the preloading API would complexify the "regular" code for in my opinion no real gain. Re-using in a separate location loading-related modules (segmentFetcher, manifestFetcher), selecting Representations through an util and a well-defined API, and then loading and storing all of their segments sequentially (code which would be written in that downloading-specific directory) seems much easier to implement.

@peaBerberian peaBerberian added work-in-progress This Pull Request or issue is not finished yet poc Proof Of Concept - This is just an attempt to ponder on Priority: 3 (Low) This issue or PR has a low priority. labels Feb 10, 2025
@peaBerberian peaBerberian force-pushed the poc/common-init branch 2 times, most recently from fdc67fd to fac1335 Compare April 22, 2025 18:28
@peaBerberian peaBerberian changed the title [POC]: Preload architecture proposal [WIP] Preload architecture proposal Apr 23, 2025
@peaBerberian peaBerberian force-pushed the poc/common-init branch 2 times, most recently from 1f9e5d2 to 964c957 Compare August 29, 2025 14:16
@peaBerberian peaBerberian force-pushed the feat/preload4 branch 2 times, most recently from 1e534f4 to 84ba4d8 Compare August 29, 2025 15:11
@peaBerberian peaBerberian changed the base branch from poc/common-init to poc/common-init-renaming August 29, 2025 15:11
@@ -262,10 +262,12 @@ function getKeySystemsOption(

Copy link
Collaborator Author

@peaBerberian peaBerberian Aug 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: The demo is here modified to be able to test quickly the feature, this would probably be scrapped before merging anything

peaBerberian added a commit that referenced this pull request Oct 13, 2025
We've recently seen `MEDIA_TIME_NOT_FOUND` errors in a Canal+
application.

It's one of the errors that are part of our API that should
actually be never sent to an application - unless there's an RxPlayer
bug somewhere (it is documented as such in our API documentation).

The issue
---------

Turns out they were encountering a very specific race condition after
a chain of events:

1. The application relied on a multi-threaded RxPlayer and played an
   encrypted content with some non-decipherable qualities.

   They also have all conditions met to allow our cache of
   `MediaKeySession` (which allows to reuse already-loaded decryption
   keys).

2. They then loaded another content, with a completely different media
   position that does not map to anything in the previous content (it
   shouldn't matter, but it will be important later)

3. The application switches again to the first content, without calling
   `stop` in between (which is OK and even more performant).

   In that situation, the following happen just after the third step:

4. We start to initialize everything for that last content.

   Since recently (#1607),
   that initialization step includes the initial polling of media
   metrics such as the position, the playbackRate etc.

   Before that work, this polling was done in a later step.

5. We stop the previous content. We do this after initializing the
   next content on purpose.

   "Stopping" a content (setting `mediaElement.src = ""`, typically) is
   a synchronous/blocking operations in JS that can actually
   take a lot of time - like hundred of ms on the lower end devices.

   To improve performance, we thus "initialize" the next content before
   stopping the previous one, as the former includes some parallelizable
   operations (network requests, `postMessage` to our Worker etc.) that
   can still take place while the "stop operation" is blocking the JS
   main thread.

6. The RxPlayer core (running in a WebWorker here) initialize everything,
   fetches the Manifest etc.

   Here the content is already known and its decryption keys are still
   "cached" locally, so we directly know that some qualities in the
   Manifest are not decipherable and decide to "fallback" from the
   higher qualities.

7. The fallback mechanism reads the last polled media metrics. If we
   reached that logic too fast, we will actually read the
   initially-polled metrics (3 steps earlier).

   This should be OK, but because we last polled the media metrics
   _BEFORE_ stopping the previous content, the playback position in
   those metrics is actually the last position reached in the previous
   content.

8. The RxPlayer checks that wanted position, see that it doesn't make
   sense in the current content, and triggers the `MEDIA_TIME_NOT_FOUND`
   error.

The fix
-------

The crux of this issue is just that we poll media metrics before
stopping the previous content, so there was a lot of ways this issue
could be easily fixed.

The fix I ended up choosing is very far from being the most straightforward
one though :D. It's a solution I already PoCed with my [preload
work](#1646) which I found
elegant in terms of code architecture.

Basically the `MediaElementPlaybackObserver` (the class doing the
polling) can now be "headless" initially: without a media element. In
that case, default media metrics are considered (position `0`, paused
etc.)

When the media element is considered "ready", it can be attached to it,
in which case "real" polling will be performed.

It wasn't done with this issue in mind, but I found that it also made
sense there: before stopping the previous content, we want to start our
polling logic but are not yet ready to attach the media element as it is
still technically playing the previous content.
Once the previous content is stopped, we can now begin to actually link
the media element to it to enable actual polling.

This makes it far from a hotfix (there's a lot of lines) but I like this
solution.
peaBerberian added a commit that referenced this pull request Oct 13, 2025
We've recently seen `MEDIA_TIME_NOT_FOUND` errors in a Canal+
application.

It's one of the errors that are part of our API that should actually be
never sent to an application - unless there's an RxPlayer bug somewhere
(it is documented as such in our API documentation).

The issue
---------

Turns out they were encountering a very specific race condition after a
chain of events:

1. The application relied on a multi-threaded RxPlayer and played an
   encrypted content with some non-decipherable qualities.

   They also have all conditions met to allow our cache of
   `MediaKeySession` (which allows to reuse already-loaded decryption
   keys).

2. They then loaded another content, with a completely different media
   position that does not map to anything in the previous content (it
   shouldn't matter, but it will be important later)

3. The application switches again to the first content, without calling
   `stop` in between (which is OK and even more performant).

   In that situation, the following happen just after the third step:

4. We start to initialize everything for that last content.

   Since recently (#1607),
   that initialization step includes the initial polling of media
   metrics such as the position, the playbackRate etc.

   Before that work, this polling was done in a later step.

5. We stop the previous content. We do this after initializing the next
   content on purpose.

   "Stopping" a content (setting `mediaElement.src = ""`, typically) is
   a synchronous/blocking operations in JS that can actually take a lot
   of time - like hundred of ms on lower-end devices.

   To improve performance, we thus "initialize" the next content before
   stopping the previous one, as the former includes some parallelizable
   operations (network requests, `postMessage` to our Worker etc.) that
   can still take place while the "stop operation" is blocking the JS
   main thread.

6. The RxPlayer core (running in a WebWorker here) initialize
   everything, fetches the Manifest etc.

   Here the content is already known and its decryption keys are still
   "cached" locally, so we directly know that some qualities in the
   Manifest are not decipherable and decide to "fallback" from the
   higher qualities.

7. The fallback mechanism reads the last polled media metrics. If we
   reached that logic too fast, we will actually read the
   initially-polled metrics (3 steps earlier).

   This should be OK, but because we last polled the media metrics
   _BEFORE_ stopping the previous content **AND** no metrics has been
   emitted since then, the playback position in those metrics is
   actually the last position reached in the previous content.

8. The RxPlayer checks that wanted position, see that it doesn't make
   sense in the current content, and triggers the `MEDIA_TIME_NOT_FOUND`
   error.

The fixes
---------

The crux of this issue is both that:

1. we poll media metrics before stopping the previous content and,
2. we do not emit new metrics immediately when the initial position to
   seek to is known

The fix I ended up choosing is very far from being the most
straightforward one though :D. It's a solution I already PoCed with my
[preload work](#1646) which I
found elegant in terms of code architecture.

Basically the `PlaybackObserver` (the class doing the polling) can now
be "headless" initially: without a media element. In that case, default
media metrics are considered (position `0`, paused etc.)

When the media element is considered "ready", it can be attached to it,
in which case "real" polling will be performed. This ensure that no
polling of media metrics linked to a previous content is going on.

It wasn't done with this issue in mind, but I found that it also made
sense there: before stopping the previous content, we want to start our
metrics-polling module (the `PlaybackObserver`) but are not yet ready to
attach the media element as it is still technically playing the previous
content. Once the previous content is stopped, we can now begin to
actually link the media element to it to enable actual polling.

Moreover, I now decide to emit those metrics right when we know what the
initial position to seek to will be (as those metrics include this data
point as a "wanted position). This makes sure that the RxPlayer core
always has the more up-to-date information.

Lastly, the already-merged #1755 fix should also have fixed that issue -
as the initial position would have been known at some point by the
RxPlayer Core anyway.

This makes it far from a hotfix (there's a lot of lines) but I like this
solution.
peaBerberian added a commit that referenced this pull request Oct 13, 2025
We've recently seen `MEDIA_TIME_NOT_FOUND` errors in a Canal+
application.

It's one of the errors that are part of our API that should actually be
never sent to an application - unless there's an RxPlayer bug somewhere
(it is documented as such in our API documentation).

The issue
---------

Turns out they were encountering a very specific race condition after a
chain of events:

1. The application relied on a multi-threaded RxPlayer and played an
   encrypted content with some non-decipherable qualities.

   They also have all conditions met to allow our cache of
   `MediaKeySession` (which allows to reuse already-loaded decryption
   keys).

2. They then loaded another content, with a completely different media
   position that does not map to anything in the previous content (it
   shouldn't matter, but it will be important later)

3. The application switches again to the first content, without calling
   `stop` in between (which is OK and even more performant).

   In that situation, the following happen just after the third step:

4. We start to initialize everything for that last content.

   Since recently (#1607),
   that initialization step includes the initial polling of media
   metrics such as the position, the playbackRate etc.

   Before that work, this polling was done in a later step.

5. We stop the previous content. We do this after initializing the next
   content on purpose.

   "Stopping" a content (setting `mediaElement.src = ""`, typically) is
   a synchronous/blocking operations in JS that can actually take a lot
   of time - like hundred of ms on lower-end devices.

   To improve performance, we thus "initialize" the next content before
   stopping the previous one, as the former includes some parallelizable
   operations (network requests, `postMessage` to our Worker etc.) that
   can still take place while the "stop operation" is blocking the JS
   main thread.

6. The RxPlayer core (running in a WebWorker here) initialize
   everything, fetches the Manifest etc.

   Here the content is already known and its decryption keys are still
   "cached" locally, so we directly know that some qualities in the
   Manifest are not decipherable and decide to "fallback" from the
   higher qualities.

7. The fallback mechanism reads the last polled media metrics. If we
   reached that logic too fast, we will actually read the
   initially-polled metrics (3 steps earlier).

   This should be OK, but because we last polled the media metrics
   _BEFORE_ stopping the previous content **AND** no metrics has been
   emitted since then, the playback position in those metrics is
   actually the last position reached in the previous content.

8. The RxPlayer checks that wanted position, see that it doesn't make
   sense in the current content, and triggers the `MEDIA_TIME_NOT_FOUND`
   error.

The fixes
---------

The crux of this issue is both that:

1. we poll media metrics before stopping the previous content and,
2. we do not emit new metrics immediately when the initial position to
   seek to is known

The fix I ended up choosing is very far from being the most
straightforward one though :D. It's a solution I already PoCed with my
[preload work](#1646) which I
found elegant in terms of code architecture.

Basically the `PlaybackObserver` (the class doing the polling) can now
be "headless" initially: without a media element. In that case, default
media metrics are considered (position `0`, paused etc.)

When the media element is considered "ready", it can be attached to it,
in which case "real" polling will be performed. This ensure that no
polling of media metrics linked to a previous content is going on.

It wasn't done with this issue in mind, but I found that it also made
sense there: before stopping the previous content, we want to start our
metrics-polling module (the `PlaybackObserver`) but are not yet ready to
attach the media element as it is still technically playing the previous
content. Once the previous content is stopped, we can now begin to
actually link the media element to it to enable actual polling.

Moreover, I now decide to emit those metrics right when we know what the
initial position to seek to will be (as those metrics include this data
point as a "wanted position). This makes sure that the RxPlayer core
always has the more up-to-date information.

Lastly, the already-merged #1755 fix should also have fixed that issue -
as the initial position would have been known at some point by the
RxPlayer Core anyway.

This makes it far from a hotfix (there's a lot of lines) but I like this
solution.
peaBerberian added a commit that referenced this pull request Oct 13, 2025
We've recently seen `MEDIA_TIME_NOT_FOUND` errors in a Canal+
application.

It's one of the errors that are part of our API that should actually be
never sent to an application - unless there's an RxPlayer bug somewhere
(it is documented as such in our API documentation).

The issue
---------

Turns out they were encountering a very specific race condition after a
chain of events:

1. The application relied on a multi-threaded RxPlayer and played an
   encrypted content with some non-decipherable qualities.

   They also have all conditions met to allow our cache of
   `MediaKeySession` (which allows to reuse already-loaded decryption
   keys).

2. They then loaded another content, with a completely different media
   position that does not map to anything in the previous content (it
   shouldn't matter, but it will be important later)

3. The application switches again to the first content, without calling
   `stop` in between (which is OK and even more performant).

   In that situation, the following happen just after the third step:

4. We start to initialize everything for that last content.

   Since recently (#1607),
   that initialization step includes the initial polling of media
   metrics such as the position, the playbackRate etc.

   Before that work, this polling was done in a later step.

5. We stop the previous content. We do this after initializing the next
   content on purpose.

   "Stopping" a content (setting `mediaElement.src = ""`, typically) is
   a synchronous/blocking operations in JS that can actually take a lot
   of time - like hundred of ms on lower-end devices.

   To improve performance, we thus "initialize" the next content before
   stopping the previous one, as the former includes some parallelizable
   operations (network requests, `postMessage` to our Worker etc.) that
   can still take place while the "stop operation" is blocking the JS
   main thread.

6. The RxPlayer core (running in a WebWorker here) initialize
   everything, fetches the Manifest etc.

   Here the content is already known and its decryption keys are still
   "cached" locally, so we directly know that some qualities in the
   Manifest are not decipherable and decide to "fallback" from the
   higher qualities.

7. The fallback mechanism reads the last polled media metrics. If we
   reached that logic too fast, we will actually read the
   initially-polled metrics (3 steps earlier).

   This should be OK, but because we last polled the media metrics
   _BEFORE_ stopping the previous content **AND** no metrics has been
   emitted since then, the playback position in those metrics is
   actually the last position reached in the previous content.

8. The RxPlayer checks that wanted position, see that it doesn't make
   sense in the current content, and triggers the `MEDIA_TIME_NOT_FOUND`
   error.

The fixes
---------

The crux of this issue is both that:

1. we poll media metrics before stopping the previous content and,
2. we do not emit new metrics immediately when the initial position to
   seek to is known

The fix I ended up choosing is very far from being the most
straightforward one though :D. It's a solution I already PoCed with my
[preload work](#1646) which I
found elegant in terms of code architecture.

Basically the `PlaybackObserver` (the class doing the polling) can now
be "headless" initially: without a media element. In that case, default
media metrics are considered (position `0`, paused etc.)

When the media element is considered "ready", it can be attached to it,
in which case "real" polling will be performed. This ensure that no
polling of media metrics linked to a previous content is going on.

It wasn't done with this issue in mind, but I found that it also made
sense there: before stopping the previous content, we want to start our
metrics-polling module (the `PlaybackObserver`) but are not yet ready to
attach the media element as it is still technically playing the previous
content. Once the previous content is stopped, we can now begin to
actually link the media element to it to enable actual polling.

Moreover, I now decide to emit those metrics right when we know what the
initial position to seek to will be (as those metrics include this data
point as a "wanted position). This makes sure that the RxPlayer core
always has the more up-to-date information.

Lastly, the already-merged #1755 fix should also have fixed that issue -
as the initial position would have been known at some point by the
RxPlayer Core anyway.

This makes it far from a hotfix (there's a lot of lines) but I like this
solution.
peaBerberian added a commit that referenced this pull request Dec 5, 2025
We've recently seen `MEDIA_TIME_NOT_FOUND` errors in a Canal+
application.

It's one of the errors that are part of our API that should actually be
never sent to an application - unless there's an RxPlayer bug somewhere
(it is documented as such in our API documentation).

The issue
---------

Turns out they were encountering a very specific race condition after a
chain of events:

1. The application relied on a multi-threaded RxPlayer and played an
   encrypted content with some non-decipherable qualities.

   They also have all conditions met to allow our cache of
   `MediaKeySession` (which allows to reuse already-loaded decryption
   keys).

2. They then loaded another content, with a completely different media
   position that does not map to anything in the previous content (it
   shouldn't matter, but it will be important later)

3. The application switches again to the first content, without calling
   `stop` in between (which is OK and even more performant).

   In that situation, the following happen just after the third step:

4. We start to initialize everything for that last content.

   Since recently (#1607),
   that initialization step includes the initial polling of media
   metrics such as the position, the playbackRate etc.

   Before that work, this polling was done in a later step.

5. We stop the previous content. We do this after initializing the next
   content on purpose.

   "Stopping" a content (setting `mediaElement.src = ""`, typically) is
   a synchronous/blocking operations in JS that can actually take a lot
   of time - like hundred of ms on lower-end devices.

   To improve performance, we thus "initialize" the next content before
   stopping the previous one, as the former includes some parallelizable
   operations (network requests, `postMessage` to our Worker etc.) that
   can still take place while the "stop operation" is blocking the JS
   main thread.

6. The RxPlayer core (running in a WebWorker here) initialize
   everything, fetches the Manifest etc.

   Here the content is already known and its decryption keys are still
   "cached" locally, so we directly know that some qualities in the
   Manifest are not decipherable and decide to "fallback" from the
   higher qualities.

7. The fallback mechanism reads the last polled media metrics. If we
   reached that logic too fast, we will actually read the
   initially-polled metrics (3 steps earlier).

   This should be OK, but because we last polled the media metrics
   _BEFORE_ stopping the previous content **AND** no metrics has been
   emitted since then, the playback position in those metrics is
   actually the last position reached in the previous content.

8. The RxPlayer checks that wanted position, see that it doesn't make
   sense in the current content, and triggers the `MEDIA_TIME_NOT_FOUND`
   error.

The fixes
---------

The crux of this issue is both that:

1. we poll media metrics before stopping the previous content and,
2. we do not emit new metrics immediately when the initial position to
   seek to is known

The fix I ended up choosing is very far from being the most
straightforward one though :D. It's a solution I already PoCed with my
[preload work](#1646) which I
found elegant in terms of code architecture.

Basically the `PlaybackObserver` (the class doing the polling) can now
be "headless" initially: without a media element. In that case, default
media metrics are considered (position `0`, paused etc.)

When the media element is considered "ready", it can be attached to it,
in which case "real" polling will be performed. This ensure that no
polling of media metrics linked to a previous content is going on.

It wasn't done with this issue in mind, but I found that it also made
sense there: before stopping the previous content, we want to start our
metrics-polling module (the `PlaybackObserver`) but are not yet ready to
attach the media element as it is still technically playing the previous
content. Once the previous content is stopped, we can now begin to
actually link the media element to it to enable actual polling.

Moreover, I now decide to emit those metrics right when we know what the
initial position to seek to will be (as those metrics include this data
point as a "wanted position). This makes sure that the RxPlayer core
always has the more up-to-date information.

Lastly, the already-merged #1755 fix should also have fixed that issue -
as the initial position would have been known at some point by the
RxPlayer Core anyway.

This makes it far from a hotfix (there's a lot of lines) but I like this
solution.
@peaBerberian peaBerberian force-pushed the feat/preload4 branch 2 times, most recently from f55a387 to a938e33 Compare December 10, 2025 17:29
@peaBerberian peaBerberian force-pushed the dev branch 2 times, most recently from d4be192 to 9ad6758 Compare December 19, 2025 19:49
peaBerberian and others added 4 commits January 8, 2026 18:26
Back in our `v4.4.0` we brought the `DummyMediaElement` feature allowing
to run the RxPlayer's logic even on environments where actual decoding
and/or decryption is not possible (incompatible codecs, DRMs, unavailable
media API etc.).
Our main use case for this are testing purposes.

While wrapping up the release, we noted that we still relied on the
actual `MediaSource` API for checking codec support, removing a key use
case of the feature: testing the RxPlayer on a content even if the
current env does not support any of its codec.

So we quickly implemented an exception in the corresponding code branch.
It works and should catch all cases, but it was nonetheless not
satisfactory for long-term maintainance.

---

To make it more explicit, I propose here to pass from the `API` to the
`Init` the `MediaSource` class that should be used to play the content.
In a `DummyMediaElement`'s case, the `MediaSource` implementation linked
to it will be provided instead of the favored `MediaSource`
implementation found on the device.

A remaining issue is that as the class is not transmitted to `core` (not
serializable), `core` will still need to rely on the device's favored
`MediaSource` implem. I thought that it wasn't an issue as a
`DummyMediaElement` is not compatible for now with "MSE-in-worker" which
is the only scenario where communicating its linked `MediaSource` would
be needed.
@peaBerberian peaBerberian force-pushed the dev branch 9 times, most recently from 0142e34 to 1fd9df3 Compare January 27, 2026 11:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

poc Proof Of Concept - This is just an attempt to ponder on Priority: 3 (Low) This issue or PR has a low priority. work-in-progress This Pull Request or issue is not finished yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant