Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
53 changes: 53 additions & 0 deletions src/controller/buffer-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ export default class BufferController extends Logger implements ComponentAPI {
[null, null],
[null, null],
];
// Handler for Safari/WebKit MediaSource workaround
private safariSourceCloseHandler: (() => void) | null = null;
// Flag indicating whether this browser needs MediaSource close recovery from bfcache (Safari/WebKit)
private needsMediaSourceCloseRecovery: boolean = false;

constructor(hls: Hls, fragmentTracker: FragmentTracker) {
super('buffer-controller', hls.logger);
Expand All @@ -138,6 +142,15 @@ export default class BufferController extends Logger implements ComponentAPI {
}

public destroy() {
// Clean up Safari/WebKit workaround listener before destroying
if (this.mediaSource && this.safariSourceCloseHandler) {
this.mediaSource.removeEventListener(
'sourceclose',
this.safariSourceCloseHandler,
);
this.safariSourceCloseHandler = null;
}

this.unregisterListeners();
this.details = null;
this.lastMpegAudioChunk = this.blockedAudioAppend = null;
Expand Down Expand Up @@ -279,6 +292,7 @@ export default class BufferController extends Logger implements ComponentAPI {
data: MediaAttachingData,
) {
const media = (this.media = data.media);
this.needsMediaSourceCloseRecovery = this.detectMediaSourceCloseIssue();
Copy link
Collaborator

@robwalch robwalch Jan 7, 2026

Choose a reason for hiding this comment

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

Sorry, I didn't mean to suggest that this should be behind a user-agent check. I'd prefer to use existing listeners, or only add new event listeners (like "pageshow") as needed to detect that the source closure resulted from backgrounding the page.

We already have a listener for 'sourceclose': _onMediaSourceClose >

  • Can we simply use that to reopen when media is still attached and the player is not being destroyed?
  • Are there any other conditions that should be considered, like video.error, hls error, or loading state?
  • When is the MediaSource closed in relation to the page being backgrounded when the browser bug presents?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, sorry for the misunderstanding! I think we have two solid options here.

  1. We can use the existing sourceclose listener / _onMediaSourceClose handler. On Safari, the MediaSource is closed when returning to the page via bfcache, so the sourceclose event fires immediately when we land back on the page. When this occurs, there's also a media error ("Load was aborted"), but I don't think that error is specific to this issue.

If media is still attached when sourceclose fires, we can trigger recovery. My only concern is understanding all scenarios where sourceclose may fire. Currently, the event also fires during media detachment, but we don't catch it because we remove the listener before the event is captured.

Something like this works:

private _onMediaSourceClose = () => {
  if (this.media) {
    this.hls.recoverMediaError();
  }
};

My concern is: what happens if we start catching the sourceclose event during media detachment in the future? I think we would try to trigger a recovery? But then again, this.media would be null, so the condition here probably protects us from this.

  1. We can add the pageshow listener from earlier to specifically account for bfcache restoration:
private _onPageShow = (event: PageTransitionEvent) => {
  if (event.persisted && this.mediaSource?.readyState === 'closed' && this.media) {
    this.hls.recoverMediaError();
  }
};

This is more explicit about handling bfcache restoration. On other browsers, the MediaSource readyState remains open when restoring from bfcache, so this check would prevent unnecessary recovery attempts. However, we would be adding a global window listener to all browsers and instances.

I pushed up a change for option 1, but happy to continue discussing.

this.transferData = this.overrides = undefined;
const MediaSource = getMediaSource(this.appendSource);
if (MediaSource) {
Expand Down Expand Up @@ -329,6 +343,12 @@ export default class BufferController extends Logger implements ComponentAPI {
ms.addEventListener('startstreaming', this._onStartStreaming);
ms.addEventListener('endstreaming', this._onEndStreaming);
}
// Safari/WebKit workaround: Add additional listener for recovery
if (this.needsMediaSourceCloseRecovery) {
const handler = this._handleSafariMediaSourceClose.bind(this);
this.safariSourceCloseHandler = handler;
Copy link
Collaborator

@robwalch robwalch Jan 7, 2026

Choose a reason for hiding this comment

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

This binding is unnecessary with arrow function properties. private _handleSafariMediaSourceClose = () => will always be involved with the current class instance context for "this". (See other examples in the project like private onMediaSeeked = () =>.)

ms.addEventListener('sourceclose', handler);
}
}

private attachTransferred() {
Expand Down Expand Up @@ -516,6 +536,14 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
mediaSource.removeEventListener('endstreaming', this._onEndStreaming);
}

// Remove Safari/WebKit workaround listener if present
if (this.safariSourceCloseHandler) {
mediaSource.removeEventListener(
'sourceclose',
this.safariSourceCloseHandler,
);
this.safariSourceCloseHandler = null;
}
this.mediaSource = null;
this._objectUrl = null;
}
Expand Down Expand Up @@ -1935,6 +1963,31 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
});
track.listeners.length = 0;
}

private detectMediaSourceCloseIssue(): boolean {
const ua = navigator.userAgent.toLowerCase();
const isSafari = /safari/.test(ua) && !/chrome/.test(ua);
const isWebKit = /webkit/.test(ua);
return isSafari || isWebKit;
}

/**
* Handles MediaSource 'sourceclose' event on Safari/WebKit.
* Safari/WebKit have a bug where MediaSource becomes invalid after bfcache restoration.
* When the user navigates back, the MediaSource is in a 'closed' state and cannot be used.
* The 'sourceclose' event fires to notify of this, but by then sourceBuffers are already
* cleaned up (length = 0).
* If sourceclose fires while media is still attached, we trigger recovery via
* recoverMediaError() to reattach media.
*/
private _handleSafariMediaSourceClose = () => {
const { media, mediaSource } = this;
if (!media || !mediaSource) {
return;
}
this.warn('MediaSource closed while media attached - triggering recovery');
this.hls.recoverMediaError();
};
}

function removeSourceChildren(node: HTMLElement) {
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/controller/buffer-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ type BufferControllerTestable = Omit<
sourceBuffers: SourceBuffersTuple;
tracks: SourceBufferTrackSet;
tracksReady: boolean;
_handleSafariMediaSourceClose: () => void;
safariSourceCloseHandler: () => void;
};

describe('BufferController', function () {
Expand Down Expand Up @@ -557,4 +559,27 @@ describe('BufferController', function () {
expect(bufferController.bufferedToEnd).to.be.true;
});
});

describe('Safari MediaSource bfcache close recovery', function () {
it('triggers recoverMediaError when MediaSource closes with media attached', function () {
const media = new MockMediaElement() as unknown as HTMLMediaElement;
const mediaSource = new MockMediaSource() as unknown as MediaSource;
const recoverMediaErrorSpy = sandbox.spy(hls, 'recoverMediaError');
bufferController.media = media;
bufferController.mediaSource = mediaSource;
bufferController._handleSafariMediaSourceClose();
expect(recoverMediaErrorSpy).to.have.been.calledOnce;
});

it('removes handler on media detaching', function () {
const mediaSource = new MockMediaSource() as unknown as MediaSource;
const spy = sandbox.spy(mediaSource, 'removeEventListener');
const handler = () => {};
bufferController.mediaSource = mediaSource;
bufferController.safariSourceCloseHandler = handler;
hls.trigger(Events.MEDIA_DETACHING, {});
expect(spy).to.have.been.calledWith('sourceclose', handler);
expect(bufferController.safariSourceCloseHandler).to.be.null;
});
});
});
Loading