|
|
@@ -0,0 +1,377 @@
|
|
|
+/*
|
|
|
+ hlsmse.js — a small HLS player built on Media Source Extensions
|
|
|
+
|
|
|
+ Safari plays HLS natively; nothing else does. The usual answer is hls.js,
|
|
|
+ but that is a large third-party bundle to vendor and keep updated. This
|
|
|
+ module covers the one case ArozOS actually serves: a single-variant,
|
|
|
+ server-generated playlist of fragmented-MP4 segments.
|
|
|
+
|
|
|
+ fMP4 is what makes this short. Segments can be appended straight into a
|
|
|
+ SourceBuffer, so there is no transport-stream demuxer here — the browser's
|
|
|
+ own MP4 parser does that work. (An MPEG-TS playlist would need thousands of
|
|
|
+ lines of demuxing, which is exactly why the server emits fMP4.)
|
|
|
+
|
|
|
+ Supported
|
|
|
+ • EVENT and VOD media playlists, including ones still being written
|
|
|
+ • #EXT-X-MAP initialisation segments
|
|
|
+ • Seeking anywhere inside the playlist, and buffer trimming behind
|
|
|
+ • Codec detection read from the init segment, so the SourceBuffer is
|
|
|
+ created with the stream's real profile rather than a guess
|
|
|
+
|
|
|
+ Not supported (by design — the server never produces them)
|
|
|
+ • Master playlists / multiple variants / bitrate switching
|
|
|
+ • MPEG-TS segments, encryption, discontinuities, subtitle renditions
|
|
|
+*/
|
|
|
+(function (global) {
|
|
|
+'use strict';
|
|
|
+
|
|
|
+// How far ahead of the playhead to keep buffered before pausing downloads.
|
|
|
+var BUFFER_AHEAD_SECONDS = 30;
|
|
|
+// How much already-played media to keep before trimming it out of the buffer.
|
|
|
+var BUFFER_BEHIND_SECONDS = 30;
|
|
|
+// Fallback codecs when the init segment cannot be parsed: the server always
|
|
|
+// encodes H.264 High + AAC-LC, so this is the right shape even if the exact
|
|
|
+// profile digits differ.
|
|
|
+var FALLBACK_CODECS = 'avc1.640029,mp4a.40.2';
|
|
|
+
|
|
|
+function isSupported() {
|
|
|
+ if (!global.MediaSource || !global.MediaSource.isTypeSupported) { return false; }
|
|
|
+ return global.MediaSource.isTypeSupported('video/mp4; codecs="' + FALLBACK_CODECS + '"');
|
|
|
+}
|
|
|
+
|
|
|
+// ─── Playlist ─────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+// Resolve a possibly-relative playlist URI against the playlist's own location.
|
|
|
+function resolveURI(uri, playlistURL) {
|
|
|
+ try { return new URL(uri, new URL(playlistURL, global.location.href)).href; }
|
|
|
+ catch (e) { return uri; }
|
|
|
+}
|
|
|
+
|
|
|
+function parsePlaylist(text, playlistURL) {
|
|
|
+ var out = { segments: [], initURL: null, ended: false, targetDuration: 4 };
|
|
|
+ var lines = String(text).replace(/\r/g, '').split('\n');
|
|
|
+ var pendingDuration = 0;
|
|
|
+
|
|
|
+ for (var i = 0; i < lines.length; i++) {
|
|
|
+ var line = lines[i].trim();
|
|
|
+ if (!line) { continue; }
|
|
|
+
|
|
|
+ if (line.charAt(0) === '#') {
|
|
|
+ if (line.indexOf('#EXTINF:') === 0) {
|
|
|
+ pendingDuration = parseFloat(line.slice(8)) || 0;
|
|
|
+ } else if (line.indexOf('#EXT-X-MAP:') === 0) {
|
|
|
+ var m = line.match(/URI="([^"]*)"/);
|
|
|
+ if (m) { out.initURL = resolveURI(m[1], playlistURL); }
|
|
|
+ } else if (line.indexOf('#EXT-X-TARGETDURATION:') === 0) {
|
|
|
+ out.targetDuration = parseFloat(line.slice(22)) || 4;
|
|
|
+ } else if (line.indexOf('#EXT-X-ENDLIST') === 0) {
|
|
|
+ out.ended = true;
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ out.segments.push({
|
|
|
+ url: resolveURI(line, playlistURL),
|
|
|
+ duration: pendingDuration,
|
|
|
+ start: 0 // filled in below
|
|
|
+ });
|
|
|
+ pendingDuration = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ var clock = 0;
|
|
|
+ for (var s = 0; s < out.segments.length; s++) {
|
|
|
+ out.segments[s].start = clock;
|
|
|
+ clock += out.segments[s].duration;
|
|
|
+ }
|
|
|
+ out.duration = clock;
|
|
|
+ return out;
|
|
|
+}
|
|
|
+
|
|
|
+// ─── Codec detection ──────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+// Walk the init segment's box tree for the sample entries, so the SourceBuffer
|
|
|
+// is created with the codecs actually present rather than an assumption.
|
|
|
+function codecsFromInitSegment(buffer) {
|
|
|
+ var view = new DataView(buffer);
|
|
|
+ var bytes = new Uint8Array(buffer);
|
|
|
+ var video = null;
|
|
|
+ var audio = null;
|
|
|
+
|
|
|
+ function fourcc(offset) {
|
|
|
+ return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Containers whose payload is simply more boxes
|
|
|
+ var containers = { moov: 8, trak: 8, mdia: 8, minf: 8, stbl: 8, stsd: 16 };
|
|
|
+
|
|
|
+ function walk(start, end) {
|
|
|
+ var offset = start;
|
|
|
+ while (offset + 8 <= end) {
|
|
|
+ var size = view.getUint32(offset);
|
|
|
+ var type = fourcc(offset + 4);
|
|
|
+ if (size < 8 || offset + size > end) { return; }
|
|
|
+
|
|
|
+ if (containers[type] !== undefined) {
|
|
|
+ walk(offset + containers[type], offset + size);
|
|
|
+ } else if (type === 'avc1' || type === 'avc3') {
|
|
|
+ video = readAvcC(offset + 8 + 78, offset + size) || 'avc1.640029';
|
|
|
+ } else if (type === 'hvc1' || type === 'hev1') {
|
|
|
+ video = type + '.1.6.L93.B0'; // rare here; the server encodes H.264
|
|
|
+ } else if (type === 'mp4a') {
|
|
|
+ audio = 'mp4a.40.2';
|
|
|
+ }
|
|
|
+ offset += size;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // avcC carries profile / constraint flags / level, which form the codec string
|
|
|
+ function readAvcC(start, end) {
|
|
|
+ var offset = start;
|
|
|
+ while (offset + 8 <= end) {
|
|
|
+ var size = view.getUint32(offset);
|
|
|
+ var type = fourcc(offset + 4);
|
|
|
+ if (size < 8 || offset + size > end) { return null; }
|
|
|
+ if (type === 'avcC') {
|
|
|
+ var profile = bytes[offset + 9];
|
|
|
+ var compat = bytes[offset + 10];
|
|
|
+ var level = bytes[offset + 11];
|
|
|
+ return 'avc1.' + hex2(profile) + hex2(compat) + hex2(level);
|
|
|
+ }
|
|
|
+ offset += size;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ function hex2(n) { return (n < 16 ? '0' : '') + n.toString(16); }
|
|
|
+
|
|
|
+ try { walk(0, bytes.length); } catch (e) { return null; }
|
|
|
+
|
|
|
+ var parts = [];
|
|
|
+ if (video) { parts.push(video); }
|
|
|
+ if (audio) { parts.push(audio); }
|
|
|
+ return parts.length ? parts.join(',') : null;
|
|
|
+}
|
|
|
+
|
|
|
+// ─── Player ───────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+function Player(videoEl, playlistURL, options) {
|
|
|
+ this.video = videoEl;
|
|
|
+ this.playlistURL = playlistURL;
|
|
|
+ this.options = options || {};
|
|
|
+ this.destroyed = false;
|
|
|
+
|
|
|
+ this.mediaSource = null;
|
|
|
+ this.sourceBuffer = null;
|
|
|
+ this.playlist = null;
|
|
|
+ this.initBuffer = null;
|
|
|
+ this.nextIndex = 0;
|
|
|
+ this.appending = false;
|
|
|
+ this.refreshTimer = null;
|
|
|
+ this.objectURL = null;
|
|
|
+
|
|
|
+ this._onSourceOpen = this._onSourceOpen.bind(this);
|
|
|
+ this._pump = this._pump.bind(this);
|
|
|
+ this._onSeeking = this._onSeeking.bind(this);
|
|
|
+
|
|
|
+ this.mediaSource = new global.MediaSource();
|
|
|
+ this.objectURL = URL.createObjectURL(this.mediaSource);
|
|
|
+ this.mediaSource.addEventListener('sourceopen', this._onSourceOpen);
|
|
|
+ this.video.addEventListener('timeupdate', this._pump);
|
|
|
+ this.video.addEventListener('seeking', this._onSeeking);
|
|
|
+ this.video.src = this.objectURL;
|
|
|
+}
|
|
|
+
|
|
|
+Player.prototype._fail = function (reason, err) {
|
|
|
+ if (this.destroyed) { return; }
|
|
|
+ if (typeof this.options.onError === 'function') { this.options.onError(reason, err); }
|
|
|
+};
|
|
|
+
|
|
|
+Player.prototype._onSourceOpen = function () {
|
|
|
+ var self = this;
|
|
|
+ if (this.destroyed) { return; }
|
|
|
+
|
|
|
+ this._loadPlaylist().then(function () {
|
|
|
+ if (self.destroyed || !self.playlist) { return; }
|
|
|
+ if (!self.playlist.initURL) {
|
|
|
+ throw new Error('playlist has no initialisation segment');
|
|
|
+ }
|
|
|
+ return self._fetch(self.playlist.initURL).then(function (buffer) {
|
|
|
+ if (self.destroyed) { return; }
|
|
|
+ self.initBuffer = buffer;
|
|
|
+
|
|
|
+ var codecs = codecsFromInitSegment(buffer) || FALLBACK_CODECS;
|
|
|
+ var mime = 'video/mp4; codecs="' + codecs + '"';
|
|
|
+ if (!global.MediaSource.isTypeSupported(mime)) {
|
|
|
+ mime = 'video/mp4; codecs="' + FALLBACK_CODECS + '"';
|
|
|
+ }
|
|
|
+
|
|
|
+ self.sourceBuffer = self.mediaSource.addSourceBuffer(mime);
|
|
|
+ self.sourceBuffer.addEventListener('updateend', self._pump);
|
|
|
+ self.sourceBuffer.addEventListener('error', function () {
|
|
|
+ self._fail('The browser rejected a media segment');
|
|
|
+ });
|
|
|
+ self._append(buffer);
|
|
|
+ });
|
|
|
+ }).catch(function (err) {
|
|
|
+ self._fail('Could not start the HLS stream', err);
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+Player.prototype._fetch = function (url) {
|
|
|
+ return fetch(url, { credentials: 'same-origin' }).then(function (response) {
|
|
|
+ if (!response.ok) { throw new Error('HTTP ' + response.status + ' for ' + url); }
|
|
|
+ return response.arrayBuffer();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+Player.prototype._loadPlaylist = function () {
|
|
|
+ var self = this;
|
|
|
+ return fetch(this.playlistURL, { credentials: 'same-origin', cache: 'no-store' })
|
|
|
+ .then(function (response) {
|
|
|
+ if (!response.ok) { throw new Error('HTTP ' + response.status + ' for the playlist'); }
|
|
|
+ return response.text();
|
|
|
+ })
|
|
|
+ .then(function (text) {
|
|
|
+ if (self.destroyed) { return; }
|
|
|
+ self.playlist = parsePlaylist(text, self.playlistURL);
|
|
|
+ self._scheduleRefresh();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// A playlist still being written grows as the transcode advances, so keep
|
|
|
+// re-reading it until the server marks it complete.
|
|
|
+Player.prototype._scheduleRefresh = function () {
|
|
|
+ var self = this;
|
|
|
+ clearTimeout(this.refreshTimer);
|
|
|
+ if (this.destroyed || !this.playlist || this.playlist.ended) { return; }
|
|
|
+
|
|
|
+ var wait = Math.max(1000, (this.playlist.targetDuration || 4) * 500);
|
|
|
+ this.refreshTimer = setTimeout(function () {
|
|
|
+ if (self.destroyed) { return; }
|
|
|
+ self._loadPlaylist().then(function () { self._pump(); })
|
|
|
+ .catch(function () { self._scheduleRefresh(); });
|
|
|
+ }, wait);
|
|
|
+};
|
|
|
+
|
|
|
+Player.prototype._bufferedAhead = function () {
|
|
|
+ var buffered = this.video.buffered;
|
|
|
+ var time = this.video.currentTime;
|
|
|
+ for (var i = 0; i < buffered.length; i++) {
|
|
|
+ if (buffered.start(i) <= time + 0.25 && time < buffered.end(i)) {
|
|
|
+ return buffered.end(i) - time;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return 0;
|
|
|
+};
|
|
|
+
|
|
|
+Player.prototype._segmentIndexForTime = function (time) {
|
|
|
+ var segments = this.playlist ? this.playlist.segments : [];
|
|
|
+ for (var i = 0; i < segments.length; i++) {
|
|
|
+ if (time < segments[i].start + segments[i].duration) { return i; }
|
|
|
+ }
|
|
|
+ return segments.length;
|
|
|
+};
|
|
|
+
|
|
|
+Player.prototype._onSeeking = function () {
|
|
|
+ if (this.destroyed || !this.playlist || !this.sourceBuffer) { return; }
|
|
|
+ var target = this.video.currentTime;
|
|
|
+
|
|
|
+ // Already buffered around the target: let the browser play it.
|
|
|
+ var buffered = this.video.buffered;
|
|
|
+ for (var i = 0; i < buffered.length; i++) {
|
|
|
+ if (buffered.start(i) <= target && target < buffered.end(i) - 0.1) { return; }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Otherwise restart the append cursor at the segment covering the target.
|
|
|
+ this.nextIndex = this._segmentIndexForTime(target);
|
|
|
+ this._pump();
|
|
|
+};
|
|
|
+
|
|
|
+// Drive downloads: keep a window buffered ahead of the playhead, and trim what
|
|
|
+// is far behind so a long session does not grow without bound.
|
|
|
+Player.prototype._pump = function () {
|
|
|
+ var self = this;
|
|
|
+ if (this.destroyed || !this.sourceBuffer || this.sourceBuffer.updating || this.appending) { return; }
|
|
|
+ if (!this.playlist) { return; }
|
|
|
+
|
|
|
+ this._trimBehind();
|
|
|
+
|
|
|
+ if (this.nextIndex >= this.playlist.segments.length) {
|
|
|
+ if (this.playlist.ended && this.mediaSource.readyState === 'open') {
|
|
|
+ try { this.mediaSource.endOfStream(); } catch (e) {}
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (this._bufferedAhead() > BUFFER_AHEAD_SECONDS) { return; }
|
|
|
+
|
|
|
+ var segment = this.playlist.segments[this.nextIndex];
|
|
|
+ this.appending = true;
|
|
|
+ this._fetch(segment.url).then(function (buffer) {
|
|
|
+ self.appending = false;
|
|
|
+ if (self.destroyed || !self.sourceBuffer) { return; }
|
|
|
+ self.nextIndex++;
|
|
|
+ self._append(buffer);
|
|
|
+ }).catch(function (err) {
|
|
|
+ self.appending = false;
|
|
|
+ self._fail('A media segment failed to load', err);
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+Player.prototype._append = function (buffer) {
|
|
|
+ if (this.destroyed || !this.sourceBuffer) { return; }
|
|
|
+ try {
|
|
|
+ this.sourceBuffer.appendBuffer(new Uint8Array(buffer));
|
|
|
+ } catch (err) {
|
|
|
+ // A full buffer is recoverable: drop what is behind and retry once.
|
|
|
+ if (err && err.name === 'QuotaExceededError') {
|
|
|
+ this._trimBehind(true);
|
|
|
+ try { this.sourceBuffer.appendBuffer(new Uint8Array(buffer)); return; } catch (e) {}
|
|
|
+ }
|
|
|
+ this._fail('The browser rejected a media segment', err);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+Player.prototype._trimBehind = function (aggressive) {
|
|
|
+ if (!this.sourceBuffer || this.sourceBuffer.updating) { return; }
|
|
|
+ var keep = aggressive ? 5 : BUFFER_BEHIND_SECONDS;
|
|
|
+ var cutoff = this.video.currentTime - keep;
|
|
|
+ if (cutoff <= 0) { return; }
|
|
|
+
|
|
|
+ var buffered = this.sourceBuffer.buffered;
|
|
|
+ if (!buffered.length) { return; }
|
|
|
+ if (buffered.start(0) < cutoff) {
|
|
|
+ try { this.sourceBuffer.remove(buffered.start(0), cutoff); } catch (e) {}
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+Player.prototype.destroy = function () {
|
|
|
+ this.destroyed = true;
|
|
|
+ clearTimeout(this.refreshTimer);
|
|
|
+ this.video.removeEventListener('timeupdate', this._pump);
|
|
|
+ this.video.removeEventListener('seeking', this._onSeeking);
|
|
|
+
|
|
|
+ if (this.sourceBuffer) {
|
|
|
+ try { this.sourceBuffer.abort(); } catch (e) {}
|
|
|
+ this.sourceBuffer = null;
|
|
|
+ }
|
|
|
+ if (this.mediaSource && this.mediaSource.readyState === 'open') {
|
|
|
+ try { this.mediaSource.endOfStream(); } catch (e) {}
|
|
|
+ }
|
|
|
+ if (this.objectURL) {
|
|
|
+ try { URL.revokeObjectURL(this.objectURL); } catch (e) {}
|
|
|
+ this.objectURL = null;
|
|
|
+ }
|
|
|
+ this.mediaSource = null;
|
|
|
+};
|
|
|
+
|
|
|
+global.MovieHLS = {
|
|
|
+ isSupported: isSupported,
|
|
|
+ attach: function (videoEl, playlistURL, options) {
|
|
|
+ return new Player(videoEl, playlistURL, options);
|
|
|
+ },
|
|
|
+ // exposed for tests
|
|
|
+ _parsePlaylist: parsePlaylist,
|
|
|
+ _codecsFromInitSegment: codecsFromInitSegment
|
|
|
+};
|
|
|
+
|
|
|
+})(window);
|