mpegts.js
Version:
HTML5 MPEG2-TS Stream Player
599 lines (520 loc) • 22.9 kB
JavaScript
/*
* Copyright (C) 2016 Bilibili. All Rights Reserved.
*
* @author zheng qian <xqq@xqq.im>
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import EventEmitter from 'events';
import Log from '../utils/logger.js';
import Browser from '../utils/browser.js';
import MSEEvents from './mse-events';
import {IllegalStateException} from '../utils/exception.js';
// Media Source Extensions controller
class MSEController {
constructor(config) {
this.TAG = 'MSEController';
this._config = config;
this._emitter = new EventEmitter();
if (this._config.isLive && this._config.autoCleanupSourceBuffer == undefined) {
// For live stream, do auto cleanup by default
this._config.autoCleanupSourceBuffer = true;
}
this.e = {
onSourceOpen: this._onSourceOpen.bind(this),
onSourceEnded: this._onSourceEnded.bind(this),
onSourceClose: this._onSourceClose.bind(this),
onStartStreaming: this._onStartStreaming.bind(this),
onEndStreaming: this._onEndStreaming.bind(this),
onQualityChange: this._onQualityChange.bind(this),
onSourceBufferError: this._onSourceBufferError.bind(this),
onSourceBufferUpdateEnd: this._onSourceBufferUpdateEnd.bind(this)
};
// Use ManagedMediaSource only if w3c MediaSource is not available (e.g. iOS Safari)
this._useManagedMediaSource = ('ManagedMediaSource' in self) && !('MediaSource' in self);
this._mediaSource = null;
this._mediaSourceObjectURL = null;
this._mediaElementProxy = null;
this._isBufferFull = false;
this._hasPendingEos = false;
this._requireSetMediaDuration = false;
this._pendingMediaDuration = 0;
this._pendingSourceBufferInit = [];
this._mimeTypes = {
video: null,
audio: null
};
this._sourceBuffers = {
video: null,
audio: null
};
this._lastInitSegments = {
video: null,
audio: null
};
this._pendingSegments = {
video: [],
audio: []
};
this._pendingRemoveRanges = {
video: [],
audio: []
};
}
destroy() {
if (this._mediaSource) {
this.shutdown();
}
if (this._mediaSourceObjectURL) {
this.revokeObjectURL();
}
this.e = null;
this._emitter.removeAllListeners();
this._emitter = null;
}
on(event, listener) {
this._emitter.addListener(event, listener);
}
off(event, listener) {
this._emitter.removeListener(event, listener);
}
initialize(mediaElementProxy) {
if (this._mediaSource) {
throw new IllegalStateException('MediaSource has been attached to an HTMLMediaElement!');
}
if (this._useManagedMediaSource) {
Log.v(this.TAG, 'Using ManagedMediaSource');
}
let ms = this._mediaSource = this._useManagedMediaSource ? new self.ManagedMediaSource() : new self.MediaSource();
ms.addEventListener('sourceopen', this.e.onSourceOpen);
ms.addEventListener('sourceended', this.e.onSourceEnded);
ms.addEventListener('sourceclose', this.e.onSourceClose);
if (this._useManagedMediaSource) {
ms.addEventListener('startstreaming', this.e.onStartStreaming);
ms.addEventListener('endstreaming', this.e.onEndStreaming);
ms.addEventListener('qualitychange', this.e.onQualityChange);
}
this._mediaElementProxy = mediaElementProxy;
}
shutdown() {
if (this._mediaSource) {
let ms = this._mediaSource;
for (let type in this._sourceBuffers) {
// pending segments should be discard
let ps = this._pendingSegments[type];
ps.splice(0, ps.length);
this._pendingSegments[type] = null;
this._pendingRemoveRanges[type] = null;
this._lastInitSegments[type] = null;
// remove all sourcebuffers
let sb = this._sourceBuffers[type];
if (sb) {
if (ms.readyState !== 'closed') {
// ms edge can throw an error: Unexpected call to method or property access
try {
ms.removeSourceBuffer(sb);
} catch (error) {
Log.e(this.TAG, error.message);
}
sb.removeEventListener('error', this.e.onSourceBufferError);
sb.removeEventListener('updateend', this.e.onSourceBufferUpdateEnd);
}
this._mimeTypes[type] = null;
this._sourceBuffers[type] = null;
}
}
if (ms.readyState === 'open') {
try {
ms.endOfStream();
} catch (error) {
Log.e(this.TAG, error.message);
}
}
this._mediaElementProxy = null;
ms.removeEventListener('sourceopen', this.e.onSourceOpen);
ms.removeEventListener('sourceended', this.e.onSourceEnded);
ms.removeEventListener('sourceclose', this.e.onSourceClose);
if (this._useManagedMediaSource) {
ms.removeEventListener('startstreaming', this.e.onStartStreaming);
ms.removeEventListener('endstreaming', this.e.onEndStreaming);
ms.removeEventListener('qualitychange', this.e.onQualityChange);
}
this._pendingSourceBufferInit = [];
this._isBufferFull = false;
this._mediaSource = null;
}
}
isManagedMediaSource() {
return this._useManagedMediaSource;
}
getObject() {
if (!this._mediaSource) {
throw new IllegalStateException('MediaSource has not been initialized yet!');
}
return this._mediaSource;
}
getHandle() {
if (!this._mediaSource) {
throw new IllegalStateException('MediaSource has not been initialized yet!');
}
return this._mediaSource.handle;
}
getObjectURL() {
if (!this._mediaSource) {
throw new IllegalStateException('MediaSource has not been initialized yet!');
}
if (this._mediaSourceObjectURL == null) {
this._mediaSourceObjectURL = URL.createObjectURL(this._mediaSource);
}
return this._mediaSourceObjectURL;
}
revokeObjectURL() {
if (this._mediaSourceObjectURL) {
URL.revokeObjectURL(this._mediaSourceObjectURL);
this._mediaSourceObjectURL = null;
}
}
appendInitSegment(initSegment, deferred = undefined) {
if (!this._mediaSource || this._mediaSource.readyState !== 'open' || this._mediaSource.streaming === false) {
// sourcebuffer creation requires mediaSource.readyState === 'open'
// so we defer the sourcebuffer creation, until sourceopen event triggered
this._pendingSourceBufferInit.push(initSegment);
// make sure that this InitSegment is in the front of pending segments queue
this._pendingSegments[initSegment.type].push(initSegment);
return;
}
let is = initSegment;
let mimeType = `${is.container}`;
if (is.codec && is.codec.length > 0) {
if (is.codec === 'opus' && Browser.safari) {
is.codec = 'Opus';
}
mimeType += `;codecs=${is.codec}`;
}
let firstInitSegment = false;
Log.v(this.TAG, 'Received Initialization Segment, mimeType: ' + mimeType);
this._lastInitSegments[is.type] = is;
if (mimeType !== this._mimeTypes[is.type]) {
if (!this._mimeTypes[is.type]) { // empty, first chance create sourcebuffer
firstInitSegment = true;
try {
let sb = this._sourceBuffers[is.type] = this._mediaSource.addSourceBuffer(mimeType);
sb.addEventListener('error', this.e.onSourceBufferError);
sb.addEventListener('updateend', this.e.onSourceBufferUpdateEnd);
} catch (error) {
Log.e(this.TAG, error.message);
this._emitter.emit(MSEEvents.ERROR, {code: error.code, msg: error.message});
return;
}
} else {
Log.v(this.TAG, `Notice: ${is.type} mimeType changed, origin: ${this._mimeTypes[is.type]}, target: ${mimeType}`);
}
this._mimeTypes[is.type] = mimeType;
}
if (!deferred) {
// deferred means this InitSegment has been pushed to pendingSegments queue
this._pendingSegments[is.type].push(is);
}
if (!firstInitSegment) { // append immediately only if init segment in subsequence
if (this._sourceBuffers[is.type] && !this._sourceBuffers[is.type].updating) {
this._doAppendSegments();
}
}
if (Browser.safari && is.container === 'audio/mpeg' && is.mediaDuration > 0) {
// 'audio/mpeg' track under Safari may cause MediaElement's duration to be NaN
// Manually correct MediaSource.duration to make progress bar seekable, and report right duration
this._requireSetMediaDuration = true;
this._pendingMediaDuration = is.mediaDuration / 1000; // in seconds
this._updateMediaSourceDuration();
}
}
appendMediaSegment(mediaSegment) {
let ms = mediaSegment;
this._pendingSegments[ms.type].push(ms);
if (this._config.autoCleanupSourceBuffer && this._needCleanupSourceBuffer()) {
this._doCleanupSourceBuffer();
}
let sb = this._sourceBuffers[ms.type];
if (sb && !sb.updating && !this._hasPendingRemoveRanges()) {
this._doAppendSegments();
}
}
flush() {
// remove all appended buffers
for (let type in this._sourceBuffers) {
if (!this._sourceBuffers[type]) {
continue;
}
// abort current buffer append algorithm
let sb = this._sourceBuffers[type];
if (this._mediaSource.readyState === 'open') {
try {
// If range removal algorithm is running, InvalidStateError will be throwed
// Ignore it.
sb.abort();
} catch (error) {
Log.e(this.TAG, error.message);
}
}
// pending segments should be discard
let ps = this._pendingSegments[type];
ps.splice(0, ps.length);
if (this._mediaSource.readyState === 'closed') {
// Parent MediaSource object has been detached from HTMLMediaElement
continue;
}
// record ranges to be remove from SourceBuffer
for (let i = 0; i < sb.buffered.length; i++) {
let start = sb.buffered.start(i);
let end = sb.buffered.end(i);
this._pendingRemoveRanges[type].push({start, end});
}
// if sb is not updating, let's remove ranges now!
if (!sb.updating) {
this._doRemoveRanges();
}
// Safari 10 may get InvalidStateError in the later appendBuffer() after SourceBuffer.remove() call
// Internal parser's state may be invalid at this time. Re-append last InitSegment to workaround.
// Related issue: https://bugs.webkit.org/show_bug.cgi?id=159230
if (Browser.safari) {
let lastInitSegment = this._lastInitSegments[type];
if (lastInitSegment) {
this._pendingSegments[type].push(lastInitSegment);
if (!sb.updating) {
this._doAppendSegments();
}
}
}
}
}
endOfStream() {
let ms = this._mediaSource;
let sb = this._sourceBuffers;
if (!ms || ms.readyState !== 'open') {
if (ms && ms.readyState === 'closed' && this._hasPendingSegments()) {
// If MediaSource hasn't turned into open state, and there're pending segments
// Mark pending endOfStream, defer call until all pending segments appended complete
this._hasPendingEos = true;
}
return;
}
if (sb.video && sb.video.updating || sb.audio && sb.audio.updating) {
// If any sourcebuffer is updating, defer endOfStream operation
// See _onSourceBufferUpdateEnd()
this._hasPendingEos = true;
} else {
this._hasPendingEos = false;
// Notify media data loading complete
// This is helpful for correcting total duration to match last media segment
// Otherwise MediaElement's ended event may not be triggered
ms.endOfStream();
}
}
_needCleanupSourceBuffer() {
if (!this._config.autoCleanupSourceBuffer) {
return false;
}
let currentTime = this._mediaElementProxy.getCurrentTime();
for (let type in this._sourceBuffers) {
let sb = this._sourceBuffers[type];
if (sb) {
let buffered = sb.buffered;
if (buffered.length >= 1) {
if (currentTime - buffered.start(0) >= this._config.autoCleanupMaxBackwardDuration) {
return true;
}
}
}
}
return false;
}
_doCleanupSourceBuffer() {
let currentTime = this._mediaElementProxy.getCurrentTime();
for (let type in this._sourceBuffers) {
let sb = this._sourceBuffers[type];
if (sb) {
let buffered = sb.buffered;
let doRemove = false;
for (let i = 0; i < buffered.length; i++) {
let start = buffered.start(i);
let end = buffered.end(i);
if (start <= currentTime && currentTime < end + 3) { // padding 3 seconds
if (currentTime - start >= this._config.autoCleanupMaxBackwardDuration) {
doRemove = true;
let removeEnd = currentTime - this._config.autoCleanupMinBackwardDuration;
this._pendingRemoveRanges[type].push({start: start, end: removeEnd});
}
} else if (end < currentTime) {
doRemove = true;
this._pendingRemoveRanges[type].push({start: start, end: end});
}
}
if (doRemove && !sb.updating) {
this._doRemoveRanges();
}
}
}
}
_updateMediaSourceDuration() {
let sb = this._sourceBuffers;
if (this._mediaElementProxy.getReadyState() === 0 || this._mediaSource.readyState !== 'open') {
return;
}
if ((sb.video && sb.video.updating) || (sb.audio && sb.audio.updating)) {
return;
}
let current = this._mediaSource.duration;
let target = this._pendingMediaDuration;
if (target > 0 && (isNaN(current) || target > current)) {
Log.v(this.TAG, `Update MediaSource duration from ${current} to ${target}`);
this._mediaSource.duration = target;
}
this._requireSetMediaDuration = false;
this._pendingMediaDuration = 0;
}
_doRemoveRanges() {
for (let type in this._pendingRemoveRanges) {
if (!this._sourceBuffers[type] || this._sourceBuffers[type].updating) {
continue;
}
let sb = this._sourceBuffers[type];
let ranges = this._pendingRemoveRanges[type];
while (ranges.length && !sb.updating) {
let range = ranges.shift();
sb.remove(range.start, range.end);
}
}
}
_doAppendSegments() {
let pendingSegments = this._pendingSegments;
for (let type in pendingSegments) {
if (!this._sourceBuffers[type] || this._sourceBuffers[type].updating || this._mediaSource.streaming === false) {
continue;
}
if (pendingSegments[type].length > 0) {
let segment = pendingSegments[type].shift();
if (typeof segment.timestampOffset === 'number' && isFinite(segment.timestampOffset)) {
// For MPEG audio stream in MSE, if unbuffered-seeking occurred
// We need explicitly set timestampOffset to the desired point in timeline for mpeg SourceBuffer.
let currentOffset = this._sourceBuffers[type].timestampOffset;
let targetOffset = segment.timestampOffset / 1000; // in seconds
let delta = Math.abs(currentOffset - targetOffset);
if (delta > 0.1) { // If time delta > 100ms
Log.v(this.TAG, `Update MPEG audio timestampOffset from ${currentOffset} to ${targetOffset}`);
this._sourceBuffers[type].timestampOffset = targetOffset;
}
delete segment.timestampOffset;
}
if (!segment.data || segment.data.byteLength === 0) {
// Ignore empty buffer
continue;
}
try {
this._sourceBuffers[type].appendBuffer(segment.data);
this._isBufferFull = false;
} catch (error) {
this._pendingSegments[type].unshift(segment);
if (error.code === 22) { // QuotaExceededError
/* Notice that FireFox may not throw QuotaExceededError if SourceBuffer is full
* Currently we can only do lazy-load to avoid SourceBuffer become scattered.
* SourceBuffer eviction policy may be changed in future version of FireFox.
*
* Related issues:
* https://bugzilla.mozilla.org/show_bug.cgi?id=1279885
* https://bugzilla.mozilla.org/show_bug.cgi?id=1280023
*/
// report buffer full, abort network IO
if (!this._isBufferFull) {
this._emitter.emit(MSEEvents.BUFFER_FULL);
}
this._isBufferFull = true;
} else {
Log.e(this.TAG, error.message);
this._emitter.emit(MSEEvents.ERROR, {code: error.code, msg: error.message});
}
}
}
}
}
_onSourceOpen() {
Log.v(this.TAG, 'MediaSource onSourceOpen');
this._mediaSource.removeEventListener('sourceopen', this.e.onSourceOpen);
// deferred sourcebuffer creation / initialization
if (this._pendingSourceBufferInit.length > 0) {
let pendings = this._pendingSourceBufferInit;
while (pendings.length) {
let segment = pendings.shift();
this.appendInitSegment(segment, true);
}
}
// there may be some pending media segments, append them
if (this._hasPendingSegments()) {
this._doAppendSegments();
}
this._emitter.emit(MSEEvents.SOURCE_OPEN);
}
_onStartStreaming() {
Log.v(this.TAG, 'ManagedMediaSource onStartStreaming');
this._emitter.emit(MSEEvents.START_STREAMING);
}
_onEndStreaming() {
Log.v(this.TAG, 'ManagedMediaSource onEndStreaming');
this._emitter.emit(MSEEvents.END_STREAMING);
}
_onQualityChange() {
Log.v(this.TAG, 'ManagedMediaSource onQualityChange');
}
_onSourceEnded() {
// fired on endOfStream
Log.v(this.TAG, 'MediaSource onSourceEnded');
}
_onSourceClose() {
// fired on detaching from media element
Log.v(this.TAG, 'MediaSource onSourceClose');
if (this._mediaSource && this.e != null) {
this._mediaSource.removeEventListener('sourceopen', this.e.onSourceOpen);
this._mediaSource.removeEventListener('sourceended', this.e.onSourceEnded);
this._mediaSource.removeEventListener('sourceclose', this.e.onSourceClose);
if (this._useManagedMediaSource) {
this._mediaSource.removeEventListener('startstreaming', this.e.onStartStreaming);
this._mediaSource.removeEventListener('endstreaming', this.e.onEndStreaming);
this._mediaSource.removeEventListener('qualitychange', this.e.onQualityChange);
}
}
}
_hasPendingSegments() {
let ps = this._pendingSegments;
return ps.video.length > 0 || ps.audio.length > 0;
}
_hasPendingRemoveRanges() {
let prr = this._pendingRemoveRanges;
return prr.video.length > 0 || prr.audio.length > 0;
}
_onSourceBufferUpdateEnd() {
if (this._requireSetMediaDuration) {
this._updateMediaSourceDuration();
} else if (this._hasPendingRemoveRanges()) {
this._doRemoveRanges();
} else if (this._hasPendingSegments()) {
this._doAppendSegments();
} else if (this._hasPendingEos) {
this.endOfStream();
}
this._emitter.emit(MSEEvents.UPDATE_END);
}
_onSourceBufferError(e) {
Log.e(this.TAG, `SourceBuffer Error: ${e}`);
// this error might not always be fatal, just ignore it
}
}
export default MSEController;