Playing HLS through Managed Media Source on iPhone with hls.js
TL;DR iPhone Safari never supported Media Source Extensions, so JS-driven players fell back to native HLS. Since iOS 17.1, Apple ships Managed Media Source (MMS), an MSE variant where the browser controls when you fetch. We'll wire up hls.js to use it, then handle the one behavior that bites people: the browser can evict your buffer.
๐ฆ Code: github.com/USER/mms-hls-demo (replace before publishing)
What we're building
A minimal HLS player page that works through Managed Media Source on iPhone (iOS 17.1+) and falls back cleanly everywhere else. We'll do it twice: first with hls.js doing the heavy lifting (what you'll actually ship), then a stripped-down raw MMS example so you understand what the library is doing under the hood.
Versions used: hls.js 1.6.13, tested on iOS 17.1+ Safari and desktop Chrome/Firefox.
1. Why the old approach fell short
On every platform except iPhone, you'd attach a MediaSource and feed it segments:
// classic MSE: works on desktop, NOT on iPhone Safari (pre-17.1)
const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);
On iPhone, window.MediaSource was simply undefined, so libraries detected that and fell back to native HLS:
// the fallback every player shipped
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = 'https://example.com/playlist.m3u8';
// browser owns everything
}
That works, but you lose JS control over buffering, ABR, DVR UI, and quality menus. MMS is the way back in.
2. Feature-detect Managed Media Source
The new global is ManagedMediaSource. Detect it alongside the classic one:
// src/detect.js
export function getMediaSource() {
return self.ManagedMediaSource || self.MediaSource || null;
}
export function hasManagedMediaSource() {
return 'ManagedMediaSource' in self;
}
๐ก Tip: self works in both window and worker contexts, which matters if you ever move media handling into a Web Worker.
3. The hls.js path (what you'll ship)
Here's the good news: hls.js 1.6.x already uses the ManagedMediaSource global by default when it's present. For most teams the "port to MMS" task is "upgrade hls.js and fix two attributes."
npm install hls.js@1.6.13
<!-- index.html -->
<video id="player" controls playsinline></video>
// src/player.js
import Hls from 'hls.js';
const video = document.getElementById('player');
const src = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
// Two attributes that matter on iOS with MMS:
video.disableRemotePlayback = true; // keeps AirPlay from hijacking the managed buffer
if (Hls.isSupported()) {
// Covers desktop MSE AND iPhone Managed Media Source, hls.js picks the right one.
const hls = new Hls({ enableWorker: true });
hls.loadSource(src);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Very old iOS or browsers without MSE/MMS: native HLS.
video.src = src;
}
That's it for the common case. The reason it's anticlimactic is the whole point: hls.js subscribes to the MMS streaming events and handles buffer eviction so you don't have to. But you should understand what it's doing, because debugging it without that mental model is miserable.
4. The raw MMS example (so you understand the library)
If you're building DASH-on-iOS or your own player, here's the shape of MMS by hand. Three things differ from classic MSE.
Difference 1: attach via a child <source>, not an object URL
// src/raw-mms.js
const MMS = self.ManagedMediaSource;
const mediaSource = new MMS();
video.disableRemotePlayback = true;
// iOS wants a child <source> element, not video.src = URL.createObjectURL(...)
const source = document.createElement('source');
source.type = 'video/mp4'; // your fMP4 mime
source.src = URL.createObjectURL(mediaSource);
video.appendChild(source);
Difference 2: only fetch between startstreaming and endstreaming
let sourceBuffer;
mediaSource.addEventListener('sourceopen', () => {
URL.revokeObjectURL(source.src);
sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.4d401f"');
});
// The browser tells YOU when it wants data. Respect it or you lose the battery win.
mediaSource.addEventListener('startstreaming', async () => {
while (mediaSource.streaming && moreSegments()) {
const chunk = await fetchNextSegment();
await appendChunk(sourceBuffer, chunk);
}
});
mediaSource.addEventListener('endstreaming', () => {
// Stop fetching. The UA has enough buffered for now.
});
Difference 3: the buffer can be evicted under you
This is the gotcha. With classic MSE, whatever you appended stayed until you removed it. With MMS, the browser can purge buffered ranges under memory pressure and fire bufferedchange:
sourceBuffer.addEventListener('bufferedchange', (e) => {
// e.addedRanges and e.removedRanges are TimeRanges-like.
for (let i = 0; i < e.removedRanges.length; i++) {
const start = e.removedRanges.start(i);
const end = e.removedRanges.end(i);
console.log(`UA evicted buffered range ${start} to ${end}s`);
// If the user seeks back into an evicted range, you must re-fetch it.
markRangeForRefetch(start, end);
}
});
โ ๏ธ Note: if you assume appended data is permanent (the MSE habit), seeking backward into an evicted range will look like a frozen player on iPhone and work fine everywhere else. This is the single most common MMS bug. hls.js handles it; hand-rolled players must.
5. Test it on a real device
Simulators lie about media. Use a physical iPhone on iOS 17.1+.
# serve over HTTPS-ish locally; iOS media needs a secure-ish context
npx vite --host
# then open the LAN URL on the iPhone, on cellular if you can
Checklist:
- [ ] Playback starts on the iPhone with the hls.js path (not the native fallback). Log which branch ran.
- [ ] AirPlay to an Apple TV hands off cleanly with
disableRemotePlayback = true. - [ ] Watch a few minutes, scrub backward a long way, confirm no freeze (the eviction case).
- [ ] Throttle to a slow profile and confirm ABR still down-switches.
6. Confirm which path actually ran
The most confusing MMS bug is thinking you're on Managed Media Source when you quietly fell back to native HLS (or vice versa). Add an explicit log so you're never guessing:
// src/which-path.js
function reportPlaybackPath(video) {
if ('ManagedMediaSource' in self && Hls.isSupported()) {
console.log('[playback] hls.js via ManagedMediaSource');
} else if (Hls.isSupported()) {
console.log('[playback] hls.js via classic MediaSource');
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
console.log('[playback] native HLS fallback');
} else {
console.warn('[playback] no supported path');
}
}
On a real iPhone running iOS 17.1+, you want to see the first line, not the native fallback. If you see the fallback, check that your hls.js version is recent enough to use the ManagedMediaSource global and that you didn't accidentally short-circuit to video.src somewhere.
You can also inspect it live from Safari's Web Inspector (connect the iPhone to a Mac, open Develop menu) and confirm 'ManagedMediaSource' in window returns true:
// in the Web Inspector console, attached to the iPhone tab
> 'ManagedMediaSource' in window
true
๐ก Tip: ship that reportPlaybackPath log behind a debug flag in production too. "Which playback path did this session use?" is the first question you'll ask when an iPhone-only bug report lands.
When you should NOT bother
Honest take: if you only target Apple devices and you're happy with native HLS, native HLS is still Apple's recommendation and it's less code. Reach for MMS when you need MSE-only powers on iPhone: MPEG-DASH on iOS, a custom DVR/quality UI, or one unified player codebase instead of a forked iOS path.
What's next
- Move segment fetching into a Web Worker and drive it from the streaming events for smoother main-thread behavior.
- If you use DASH, look at dash.js / Shaka MMS support to drop your separate iOS HLS packaging step.
- Read the WebKit AirPlay-with-MSE post for the child-
<source>handoff details.
If your player library is current, you may already be on MMS and not know it. Add a one-line log for which code path runs on iOS and go check.
Comments
No comments yet. Start the discussion.