From b8195e5ef9d7a837dfebf9c1e17df39ee9891bef Mon Sep 17 00:00:00 2001 From: Trek H Date: Mon, 20 Jan 2020 15:20:13 +1030 Subject: [PATCH 01/15] mjpeg-player: reduced config vars and events Reduced the lengthy lists of config vars and events used by hlsjs player that we no longer need. Also removed typescript typing from config.js --- cmd/mjpeg-player/hlsjs/config.js | 204 ++----------------------------- cmd/mjpeg-player/hlsjs/events.js | 80 +----------- 2 files changed, 10 insertions(+), 274 deletions(-) diff --git a/cmd/mjpeg-player/hlsjs/config.js b/cmd/mjpeg-player/hlsjs/config.js index efc8e27c..623d9367 100644 --- a/cmd/mjpeg-player/hlsjs/config.js +++ b/cmd/mjpeg-player/hlsjs/config.js @@ -1,170 +1,9 @@ -/** - * HLS config - */ - -import AbrController from './controller/abr-controller'; -import BufferController from './controller/buffer-controller'; -import CapLevelController from './controller/cap-level-controller'; -import FPSController from './controller/fps-controller'; -import XhrLoader from './utils/xhr-loader'; -// import FetchLoader from './utils/fetch-loader'; - -import AudioTrackController from './controller/audio-track-controller'; -import AudioStreamController from './controller/audio-stream-controller'; - -import * as Cues from './utils/cues'; -import TimelineController from './controller/timeline-controller'; -import SubtitleTrackController from './controller/subtitle-track-controller'; -import { SubtitleStreamController } from './controller/subtitle-stream-controller'; -import EMEController from './controller/eme-controller'; -import { requestMediaKeySystemAccess, MediaKeyFunc } from './utils/mediakeys-helper'; - -type ABRControllerConfig = { - abrEwmaFastLive: number, - abrEwmaSlowLive: number, - abrEwmaFastVoD: number, - abrEwmaSlowVoD: number, - abrEwmaDefaultEstimate: number, - abrBandWidthFactor: number, - abrBandWidthUpFactor: number, - abrMaxWithRealBitrate: boolean, - maxStarvationDelay: number, - maxLoadingDelay: number, -}; - -export type BufferControllerConfig = { - appendErrorMaxRetry: number, - liveDurationInfinity: boolean, - liveBackBufferLength: number, -}; - -type CapLevelControllerConfig = { - capLevelToPlayerSize: boolean -}; - -export type EMEControllerConfig = { - licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void, - emeEnabled: boolean, - widevineLicenseUrl?: string, - requestMediaKeySystemAccessFunc: MediaKeyFunc | null, -}; - -type FragmentLoaderConfig = { - fLoader: any, // TODO(typescript-loader): Once Loader is typed fill this in - - fragLoadingTimeOut: number, - fragLoadingMaxRetry: number, - fragLoadingRetryDelay: number, - fragLoadingMaxRetryTimeout: number, -}; - -type FPSControllerConfig = { - capLevelOnFPSDrop: boolean, - fpsDroppedMonitoringPeriod: number, - fpsDroppedMonitoringThreshold: number, -}; - -type LevelControllerConfig = { - startLevel?: number -}; - -type MP4RemuxerConfig = { - stretchShortVideoTrack: boolean, - maxAudioFramesDrift: number, -}; - -type PlaylistLoaderConfig = { - pLoader: any, // TODO(typescript-loader): Once Loader is typed fill this in - - manifestLoadingTimeOut: number, - manifestLoadingMaxRetry: number, - manifestLoadingRetryDelay: number, - manifestLoadingMaxRetryTimeout: number, - - levelLoadingTimeOut: number, - levelLoadingMaxRetry: number, - levelLoadingRetryDelay: number, - levelLoadingMaxRetryTimeout: number -}; - -type StreamControllerConfig = { - autoStartLoad: boolean, - startPosition: number, - defaultAudioCodec?: string, - initialLiveManifestSize: number, - maxBufferLength: number, - maxBufferSize: number, - maxBufferHole: number, - - lowBufferWatchdogPeriod: number, - highBufferWatchdogPeriod: number, - nudgeOffset: number, - nudgeMaxRetry: number, - maxFragLookUpTolerance: number, - liveSyncDurationCount: number, - liveMaxLatencyDurationCount: number, - liveSyncDuration?: number, - liveMaxLatencyDuration?: number, - maxMaxBufferLength: number, - - startFragPrefetch: boolean, -}; - -type TimelineControllerConfig = { - cueHandler: any, // TODO(typescript-cues): Type once file is done - enableCEA708Captions: boolean, - enableWebVTT: boolean, - captionsTextTrack1Label: string, - captionsTextTrack1LanguageCode: string, - captionsTextTrack2Label: string, - captionsTextTrack2LanguageCode: string, -}; - -type TSDemuxerConfig = { - forceKeyFrameOnDiscontinuity: boolean, -}; - -export type HlsConfig = - { - debug: boolean, - enableWorker: boolean, - enableSoftwareAES: boolean, - minAutoBitrate: number, - loader: any, // TODO(typescript-xhrloader): Type once XHR is done - xhrSetup?: (xhr: XMLHttpRequest, url: string) => void, - - // Alt Audio - audioStreamController?: any, // TODO(typescript-audiostreamcontroller): Type once file is done - audioTrackController?: any, // TODO(typescript-audiotrackcontroller): Type once file is done - // Subtitle - subtitleStreamController?: any, // TODO(typescript-subtitlestreamcontroller): Type once file is done - subtitleTrackController?: any, // TODO(typescript-subtitletrackcontroller): Type once file is done - timelineController?: any, // TODO(typescript-timelinecontroller): Type once file is done - // EME - emeController?: typeof EMEController, - - abrController: any, // TODO(typescript-abrcontroller): Type once file is done - bufferController: typeof BufferController, - capLevelController: any, // TODO(typescript-caplevelcontroller): Type once file is done - fpsController: any, // TODO(typescript-fpscontroller): Type once file is done - } & - ABRControllerConfig & - BufferControllerConfig & - CapLevelControllerConfig & - EMEControllerConfig & - FPSControllerConfig & - FragmentLoaderConfig & - LevelControllerConfig & - MP4RemuxerConfig & - PlaylistLoaderConfig & - StreamControllerConfig & - Partial & - TSDemuxerConfig; +import XhrLoader from './utils/xhr-loader.js'; // If possible, keep hlsDefaultConfig shallow // It is cloned whenever a new Hls instance is created, by keeping the config // shallow the properties are cloned, and we don't end up manipulating the default -export const hlsDefaultConfig: HlsConfig = { +export const hlsDefaultConfig = { autoStartLoad: true, // used by stream-controller startPosition: -1, // used by stream-controller defaultAudioCodec: void 0, // used by stream-controller @@ -213,11 +52,11 @@ export const hlsDefaultConfig: HlsConfig = { pLoader: void 0, // used by playlist-loader xhrSetup: void 0, // used by xhr-loader licenseXhrSetup: void 0, // used by eme-controller - // fetchSetup: void 0, - abrController: AbrController, - bufferController: BufferController, - capLevelController: CapLevelController, - fpsController: FPSController, + fetchSetup: void 0, + // abrController: AbrController, + // bufferController: BufferController, + // capLevelController: CapLevelController, + // fpsController: FPSController, stretchShortVideoTrack: false, // used by mp4-remuxer maxAudioFramesDrift: 1, // used by mp4-remuxer forceKeyFrameOnDiscontinuity: true, // used by ts-demuxer @@ -234,31 +73,6 @@ export const hlsDefaultConfig: HlsConfig = { minAutoBitrate: 0, // used by hls emeEnabled: false, // used by eme-controller widevineLicenseUrl: void 0, // used by eme-controller - requestMediaKeySystemAccessFunc: requestMediaKeySystemAccess, // used by eme-controller + // requestMediaKeySystemAccessFunc: requestMediaKeySystemAccess, // used by eme-controller - // Dynamic Modules - ...timelineConfig(), - subtitleStreamController: (__USE_SUBTITLES__) ? SubtitleStreamController : void 0, - subtitleTrackController: (__USE_SUBTITLES__) ? SubtitleTrackController : void 0, - timelineController: (__USE_SUBTITLES__) ? TimelineController : void 0, - audioStreamController: (__USE_ALT_AUDIO__) ? AudioStreamController : void 0, - audioTrackController: (__USE_ALT_AUDIO__) ? AudioTrackController : void 0, - emeController: (__USE_EME_DRM__) ? EMEController : void 0 -}; - -function timelineConfig (): TimelineControllerConfig { - if (!__USE_SUBTITLES__) { - // intentionally doing this over returning Partial above - // this has the added nice property of still requiring the object below to completely define all props. - return {} as any; - } - return { - cueHandler: Cues, // used by timeline-controller - enableCEA708Captions: true, // used by timeline-controller - enableWebVTT: true, // used by timeline-controller - captionsTextTrack1Label: 'English', // used by timeline-controller - captionsTextTrack1LanguageCode: 'en', // used by timeline-controller - captionsTextTrack2Label: 'Spanish', // used by timeline-controller - captionsTextTrack2LanguageCode: 'es' // used by timeline-controller - }; -} +}; \ No newline at end of file diff --git a/cmd/mjpeg-player/hlsjs/events.js b/cmd/mjpeg-player/hlsjs/events.js index 82314117..aa0cbb06 100644 --- a/cmd/mjpeg-player/hlsjs/events.js +++ b/cmd/mjpeg-player/hlsjs/events.js @@ -3,108 +3,30 @@ * @enum {string} */ const HlsEvents = { - // fired before MediaSource is attaching to media element - data: { media } - MEDIA_ATTACHING: 'hlsMediaAttaching', - // fired when MediaSource has been succesfully attached to media element - data: { } - MEDIA_ATTACHED: 'hlsMediaAttached', - // fired before detaching MediaSource from media element - data: { } - MEDIA_DETACHING: 'hlsMediaDetaching', - // fired when MediaSource has been detached from media element - data: { } - MEDIA_DETACHED: 'hlsMediaDetached', - // fired when we buffer is going to be reset - data: { } - BUFFER_RESET: 'hlsBufferReset', - // fired when we know about the codecs that we need buffers for to push into - data: {tracks : { container, codec, levelCodec, initSegment, metadata }} - BUFFER_CODECS: 'hlsBufferCodecs', - // fired when sourcebuffers have been created - data: { tracks : tracks } - BUFFER_CREATED: 'hlsBufferCreated', - // fired when we append a segment to the buffer - data: { segment: segment object } - BUFFER_APPENDING: 'hlsBufferAppending', - // fired when we are done with appending a media segment to the buffer - data : { parent : segment parent that triggered BUFFER_APPENDING, pending : nb of segments waiting for appending for this segment parent} - BUFFER_APPENDED: 'hlsBufferAppended', - // fired when the stream is finished and we want to notify the media buffer that there will be no more data - data: { } - BUFFER_EOS: 'hlsBufferEos', - // fired when the media buffer should be flushed - data { startOffset, endOffset } - BUFFER_FLUSHING: 'hlsBufferFlushing', - // fired when the media buffer has been flushed - data: { } - BUFFER_FLUSHED: 'hlsBufferFlushed', // fired to signal that a manifest loading starts - data: { url : manifestURL} MANIFEST_LOADING: 'hlsManifestLoading', // fired after manifest has been loaded - data: { levels : [available quality levels], audioTracks : [ available audio tracks], url : manifestURL, stats : { trequest, tfirst, tload, mtime}} MANIFEST_LOADED: 'hlsManifestLoaded', - // fired after manifest has been parsed - data: { levels : [available quality levels], firstLevel : index of first quality level appearing in Manifest} - MANIFEST_PARSED: 'hlsManifestParsed', - // fired when a level switch is requested - data: { level : id of new level } - LEVEL_SWITCHING: 'hlsLevelSwitching', - // fired when a level switch is effective - data: { level : id of new level } - LEVEL_SWITCHED: 'hlsLevelSwitched', // fired when a level playlist loading starts - data: { url : level URL, level : id of level being loaded} LEVEL_LOADING: 'hlsLevelLoading', // fired when a level playlist loading finishes - data: { details : levelDetails object, level : id of loaded level, stats : { trequest, tfirst, tload, mtime} } LEVEL_LOADED: 'hlsLevelLoaded', // fired when a level's details have been updated based on previous details, after it has been loaded - data: { details : levelDetails object, level : id of updated level } LEVEL_UPDATED: 'hlsLevelUpdated', - // fired when a level's PTS information has been updated after parsing a fragment - data: { details : levelDetails object, level : id of updated level, drift: PTS drift observed when parsing last fragment } - LEVEL_PTS_UPDATED: 'hlsLevelPtsUpdated', - // fired to notify that audio track lists has been updated - data: { audioTracks : audioTracks } - AUDIO_TRACKS_UPDATED: 'hlsAudioTracksUpdated', - // fired when an audio track switching is requested - data: { id : audio track id } - AUDIO_TRACK_SWITCHING: 'hlsAudioTrackSwitching', - // fired when an audio track switch actually occurs - data: { id : audio track id } - AUDIO_TRACK_SWITCHED: 'hlsAudioTrackSwitched', // fired when an audio track loading starts - data: { url : audio track URL, id : audio track id } AUDIO_TRACK_LOADING: 'hlsAudioTrackLoading', // fired when an audio track loading finishes - data: { details : levelDetails object, id : audio track id, stats : { trequest, tfirst, tload, mtime } } AUDIO_TRACK_LOADED: 'hlsAudioTrackLoaded', - // fired to notify that subtitle track lists has been updated - data: { subtitleTracks : subtitleTracks } - SUBTITLE_TRACKS_UPDATED: 'hlsSubtitleTracksUpdated', - // fired when an subtitle track switch occurs - data: { id : subtitle track id } - SUBTITLE_TRACK_SWITCH: 'hlsSubtitleTrackSwitch', // fired when a subtitle track loading starts - data: { url : subtitle track URL, id : subtitle track id } SUBTITLE_TRACK_LOADING: 'hlsSubtitleTrackLoading', // fired when a subtitle track loading finishes - data: { details : levelDetails object, id : subtitle track id, stats : { trequest, tfirst, tload, mtime } } SUBTITLE_TRACK_LOADED: 'hlsSubtitleTrackLoaded', - // fired when a subtitle fragment has been processed - data: { success : boolean, frag : the processed frag } - SUBTITLE_FRAG_PROCESSED: 'hlsSubtitleFragProcessed', - // fired when the first timestamp is found - data: { id : demuxer id, initPTS: initPTS, frag : fragment object } - INIT_PTS_FOUND: 'hlsInitPtsFound', // fired when a fragment loading starts - data: { frag : fragment object } FRAG_LOADING: 'hlsFragLoading', // fired when a fragment loading is progressing - data: { frag : fragment object, { trequest, tfirst, loaded } } FRAG_LOAD_PROGRESS: 'hlsFragLoadProgress', - // Identifier for fragment load aborting for emergency switch down - data: { frag : fragment object } - FRAG_LOAD_EMERGENCY_ABORTED: 'hlsFragLoadEmergencyAborted', // fired when a fragment loading is completed - data: { frag : fragment object, payload : fragment payload, stats : { trequest, tfirst, tload, length } } - FRAG_LOADED: 'hlsFragLoaded', - // fired when a fragment has finished decrypting - data: { id : demuxer id, frag: fragment object, payload : fragment payload, stats : { tstart, tdecrypt } } - FRAG_DECRYPTED: 'hlsFragDecrypted', - // fired when Init Segment has been extracted from fragment - data: { id : demuxer id, frag: fragment object, moov : moov MP4 box, codecs : codecs found while parsing fragment } - FRAG_PARSING_INIT_SEGMENT: 'hlsFragParsingInitSegment', - // fired when parsing sei text is completed - data: { id : demuxer id, frag: fragment object, samples : [ sei samples pes ] } - FRAG_PARSING_USERDATA: 'hlsFragParsingUserdata', - // fired when parsing id3 is completed - data: { id : demuxer id, frag: fragment object, samples : [ id3 samples pes ] } - FRAG_PARSING_METADATA: 'hlsFragParsingMetadata', - // fired when data have been extracted from fragment - data: { id : demuxer id, frag: fragment object, data1 : moof MP4 box or TS fragments, data2 : mdat MP4 box or null} - FRAG_PARSING_DATA: 'hlsFragParsingData', - // fired when fragment parsing is completed - data: { id : demuxer id, frag: fragment object } - FRAG_PARSED: 'hlsFragParsed', - // fired when fragment remuxed MP4 boxes have all been appended into SourceBuffer - data: { id : demuxer id, frag : fragment object, stats : { trequest, tfirst, tload, tparsed, tbuffered, length, bwEstimate } } - FRAG_BUFFERED: 'hlsFragBuffered', - // fired when fragment matching with current media position is changing - data : { id : demuxer id, frag : fragment object } - FRAG_CHANGED: 'hlsFragChanged', - // Identifier for a FPS drop event - data: { curentDropped, currentDecoded, totalDroppedFrames } - FPS_DROP: 'hlsFpsDrop', - // triggered when FPS drop triggers auto level capping - data: { level, droppedlevel } - FPS_DROP_LEVEL_CAPPING: 'hlsFpsDropLevelCapping', - // Identifier for an error event - data: { type : error type, details : error details, fatal : if true, hls.js cannot/will not try to recover, if false, hls.js will try to recover,other error specific data } - ERROR: 'hlsError', - // fired when hls.js instance starts destroying. Different from MEDIA_DETACHED as one could want to detach and reattach a media to the instance of hls.js to handle mid-rolls for example - data: { } - DESTROYING: 'hlsDestroying', - // fired when a decrypt key loading starts - data: { frag : fragment object } - KEY_LOADING: 'hlsKeyLoading', - // fired when a decrypt key loading is completed - data: { frag : fragment object, payload : key payload, stats : { trequest, tfirst, tload, length } } - KEY_LOADED: 'hlsKeyLoaded', - // fired upon stream controller state transitions - data: { previousState, nextState } - STREAM_STATE_TRANSITION: 'hlsStreamStateTransition' + FRAG_LOADED: 'hlsFragLoaded' }; export default HlsEvents; From 290ba3ae3fd6e696bb5f15cb5e54946c25aea9fe Mon Sep 17 00:00:00 2001 From: Trek H Date: Mon, 20 Jan 2020 15:42:22 +1030 Subject: [PATCH 02/15] mjpeg-player: conversion from typescript to javascript code Changing the typescript code to javascript code. Updated import statements. --- cmd/mjpeg-player/hlsjs/event-handler.js | 32 ++- cmd/mjpeg-player/hlsjs/loader/fragment.js | 130 ++++++------ cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js | 203 +++++++++---------- cmd/mjpeg-player/hlsjs/observer.js | 4 +- cmd/mjpeg-player/hlsjs/types/loader.js | 135 +----------- cmd/mjpeg-player/hlsjs/utils/codecs.js | 6 +- 6 files changed, 190 insertions(+), 320 deletions(-) diff --git a/cmd/mjpeg-player/hlsjs/event-handler.js b/cmd/mjpeg-player/hlsjs/event-handler.js index 06c089da..b7711eb5 100644 --- a/cmd/mjpeg-player/hlsjs/event-handler.js +++ b/cmd/mjpeg-player/hlsjs/event-handler.js @@ -3,11 +3,7 @@ * All objects in the event handling chain should inherit from this class * */ - -import { logger } from './utils/logger'; -import { ErrorTypes, ErrorDetails } from './errors'; -import Event from './events'; -import Hls from './hls'; +import Event from './events.js'; const FORBIDDEN_EVENT_NAMES = { 'hlsEventGeneric': true, @@ -16,11 +12,7 @@ const FORBIDDEN_EVENT_NAMES = { }; class EventHandler { - hls: Hls; - handledEvents: any[]; - useGenericHandler: boolean; - - constructor (hls: Hls, ...events: any[]) { + constructor(hls, ...events) { this.hls = hls; this.onEvent = this.onEvent.bind(this); this.handledEvents = events; @@ -29,20 +21,20 @@ class EventHandler { this.registerListeners(); } - destroy () { + destroy() { this.onHandlerDestroying(); this.unregisterListeners(); this.onHandlerDestroyed(); } - protected onHandlerDestroying () {} - protected onHandlerDestroyed () {} + onHandlerDestroying() { } + onHandlerDestroyed() { } - isEventHandler () { + isEventHandler() { return typeof this.handledEvents === 'object' && this.handledEvents.length && typeof this.onEvent === 'function'; } - registerListeners () { + registerListeners() { if (this.isEventHandler()) { this.handledEvents.forEach(function (event) { if (FORBIDDEN_EVENT_NAMES[event]) { @@ -54,7 +46,7 @@ class EventHandler { } } - unregisterListeners () { + unregisterListeners() { if (this.isEventHandler()) { this.handledEvents.forEach(function (event) { this.hls.off(event, this.onEvent); @@ -65,12 +57,12 @@ class EventHandler { /** * arguments: event (string), data (any) */ - onEvent (event: string, data: any) { + onEvent(event, data) { this.onEventGeneric(event, data); } - onEventGeneric (event: string, data: any) { - let eventToFunction = function (event: string, data: any) { + onEventGeneric(event, data) { + let eventToFunction = function (event, data) { let funcName = 'on' + event.replace('hls', ''); if (typeof this[funcName] !== 'function') { throw new Error(`Event ${event} has no generic handler in this ${this.constructor.name} class (tried ${funcName})`); @@ -81,7 +73,7 @@ class EventHandler { try { eventToFunction.call(this, event, data).call(); } catch (err) { - logger.error(`An internal error happened while handling event ${event}. Error message: "${err.message}". Here is a stacktrace:`, err); + console.error(`An internal error happened while handling event ${event}. Error message: "${err.message}". Here is a stacktrace:`, err); this.hls.trigger(Event.ERROR, { type: ErrorTypes.OTHER_ERROR, details: ErrorDetails.INTERNAL_EXCEPTION, fatal: false, event: event, err: err }); } } diff --git a/cmd/mjpeg-player/hlsjs/loader/fragment.js b/cmd/mjpeg-player/hlsjs/loader/fragment.js index fb7c0532..4d2eb659 100644 --- a/cmd/mjpeg-player/hlsjs/loader/fragment.js +++ b/cmd/mjpeg-player/hlsjs/loader/fragment.js @@ -1,69 +1,67 @@ +import URLToolkit from '../../url-toolkit/url-toolkit.js'; +import LevelKey from './level-key.js'; -import { buildAbsoluteURL } from 'url-toolkit'; -import { logger } from '../utils/logger'; -import LevelKey from './level-key'; -import { PlaylistLevelType } from '../types/loader'; - -export enum ElementaryStreamTypes { - AUDIO = 'audio', - VIDEO = 'video', +export const ElementaryStreamTypes = { + AUDIO: 'audio', + VIDEO: 'video' } export default class Fragment { - private _url: string | null = null; - private _byteRange: number[] | null = null; - private _decryptdata: LevelKey | null = null; + constructor() { + this._url = null; + this._byteRange = null; + this._decryptdata = null; - // Holds the types of data this fragment supports - private _elementaryStreams: Record = { - [ElementaryStreamTypes.AUDIO]: false, - [ElementaryStreamTypes.VIDEO]: false - }; + // Holds the types of data this fragment supports + this._elementaryStreams = { + [ElementaryStreamTypes.AUDIO]: false, + [ElementaryStreamTypes.VIDEO]: false + }; - // deltaPTS tracks the change in presentation timestamp between fragments - public deltaPTS: number = 0; + // deltaPTS tracks the change in presentation timestamp between fragments + this.deltaPTS = 0; - public rawProgramDateTime: string | null = null; - public programDateTime: number | null = null; - public title: string | null = null; - public tagList: Array = []; + this.rawProgramDateTime = null; + this.programDateTime = null; + this.title = null; + this.tagList = []; - // TODO: Move at least baseurl to constructor. - // Currently we do a two-pass construction as use the Fragment class almost like a object for holding parsing state. - // It may make more sense to just use a POJO to keep state during the parsing phase. - // Have Fragment be the representation once we have a known state? - // Something to think on. + // TODO: Move at least baseurl to constructor. + // Currently we do a two-pass construction as use the Fragment class almost like a object for holding parsing state. + // It may make more sense to just use a POJO to keep state during the parsing phase. + // Have Fragment be the representation once we have a known state? + // Something to think on. - // Discontinuity Counter - public cc!: number; + // Discontinuity Counter + this.cc; + this.type; + // relurl is the portion of the URL that comes from inside the playlist. + this.relurl; + // baseurl is the URL to the playlist + this.baseurl; + // EXTINF has to be present for a m3u8 to be considered valid + this.duration; + // When this segment starts in the timeline + this.start; + // sn notates the sequence number for a segment, and if set to a string can be 'initSegment' + this.sn = 0; - public type!: PlaylistLevelType; - // relurl is the portion of the URL that comes from inside the playlist. - public relurl!: string; - // baseurl is the URL to the playlist - public baseurl!: string; - // EXTINF has to be present for a m3u8 to be considered valid - public duration!: number; - // When this segment starts in the timeline - public start!: number; - // sn notates the sequence number for a segment, and if set to a string can be 'initSegment' - public sn: number | 'initSegment' = 0; + this.urlId = 0; + // level matches this fragment to a index playlist + this.level = 0; + // levelkey is the EXT-X-KEY that applies to this segment for decryption + // core difference from the private field _decryptdata is the lack of the initialized IV + // _decryptdata will set the IV for this segment based on the segment number in the fragment + this.levelkey; - public urlId: number = 0; - // level matches this fragment to a index playlist - public level: number = 0; - // levelkey is the EXT-X-KEY that applies to this segment for decryption - // core difference from the private field _decryptdata is the lack of the initialized IV - // _decryptdata will set the IV for this segment based on the segment number in the fragment - public levelkey?: LevelKey; - - // TODO(typescript-xhrloader) - public loader: any; + // TODO(typescript-xhrloader) + this.loader; + } // setByteRange converts a EXT-X-BYTERANGE attribute into a two element array - setByteRange (value: string, previousFrag?: Fragment) { + setByteRange(value, previousFrag) { const params = value.split('@', 2); - const byteRange: number[] = []; + const byteRange = []; if (params.length === 1) { byteRange[0] = previousFrag ? previousFrag.byteRangeEndOffset : 0; } else { @@ -73,19 +71,19 @@ export default class Fragment { this._byteRange = byteRange; } - get url () { + get url() { if (!this._url && this.relurl) { - this._url = buildAbsoluteURL(this.baseurl, this.relurl, { alwaysNormalize: true }); + this._url = URLToolkit.buildAbsoluteURL(this.baseurl, this.relurl, { alwaysNormalize: true }); } return this._url; } - set url (value) { + set url(value) { this._url = value; } - get byteRange (): number[] { + get byteRange() { if (!this._byteRange) { return []; } @@ -96,15 +94,15 @@ export default class Fragment { /** * @type {number} */ - get byteRangeStartOffset () { + get byteRangeStartOffset() { return this.byteRange[0]; } - get byteRangeEndOffset () { + get byteRangeEndOffset() { return this.byteRange[1]; } - get decryptdata (): LevelKey | null { + get decryptdata() { if (!this.levelkey && !this._decryptdata) { return null; } @@ -116,7 +114,7 @@ export default class Fragment { // If the segment was encrypted with AES-128 // It must have an IV defined. We cannot substitute the Segment Number in. if (this.levelkey && this.levelkey.method === 'AES-128' && !this.levelkey.iv) { - logger.warn(`missing IV for initialization segment with method="${this.levelkey.method}" - compliance issue`); + console.warn(`missing IV for initialization segment with method="${this.levelkey.method}" - compliance issue`); } /* @@ -134,7 +132,7 @@ export default class Fragment { return this._decryptdata; } - get endProgramDateTime () { + get endProgramDateTime() { if (this.programDateTime === null) { return null; } @@ -148,21 +146,21 @@ export default class Fragment { return this.programDateTime + (duration * 1000); } - get encrypted () { + get encrypted() { return !!((this.decryptdata && this.decryptdata.uri !== null) && (this.decryptdata.key === null)); } /** * @param {ElementaryStreamTypes} type */ - addElementaryStream (type: ElementaryStreamTypes) { + addElementaryStream(type) { this._elementaryStreams[type] = true; } /** * @param {ElementaryStreamTypes} type */ - hasElementaryStream (type: ElementaryStreamTypes) { + hasElementaryStream(type) { return this._elementaryStreams[type] === true; } @@ -171,7 +169,7 @@ export default class Fragment { * @param {number} segmentNumber - segment number to generate IV with * @returns {Uint8Array} */ - createInitializationVector (segmentNumber: number): Uint8Array { + createInitializationVector(segmentNumber) { let uint8View = new Uint8Array(16); for (let i = 12; i < 16; i++) { @@ -187,7 +185,7 @@ export default class Fragment { * @param segmentNumber - the fragment's segment number * @returns {LevelKey} - an object to be applied as a fragment's decryptdata */ - setDecryptDataFromLevelKey (levelkey: LevelKey, segmentNumber: number): LevelKey { + setDecryptDataFromLevelKey(levelkey, segmentNumber) { let decryptdata = levelkey; if (levelkey && levelkey.method && levelkey.uri && !levelkey.iv) { diff --git a/cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js b/cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js index ea4c2538..ab03fab1 100644 --- a/cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js +++ b/cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js @@ -1,14 +1,9 @@ -import * as URLToolkit from 'url-toolkit'; - -import Fragment from './fragment'; -import Level from './level'; -import LevelKey from './level-key'; - -import AttrList from '../utils/attr-list'; -import { logger } from '../utils/logger'; -import { isCodecType, CodecType } from '../utils/codecs'; -import { MediaPlaylist, AudioGroup, MediaPlaylistType } from '../types/media-playlist'; -import { PlaylistLevelType } from '../types/loader'; +import URLToolkit from '../../url-toolkit/url-toolkit.js'; +import Fragment from './fragment.js'; +import Level from './level.js'; +import LevelKey from './level-key.js'; +import AttrList from '../utils/attr-list.js'; +import { isCodecType } from '../utils/codecs.js'; /** * M3U8 parser @@ -32,7 +27,7 @@ const LEVEL_PLAYLIST_REGEX_SLOW = /(?:(?:#(EXTM3U))|(?:#EXT-X-(PLAYLIST-TYPE):(. const MP4_REGEX_SUFFIX = /\.(mp4|m4s|m4v|m4a)$/i; export default class M3U8Parser { - static findGroup (groups: Array, mediaGroupId: string): AudioGroup | undefined { + static findGroup(groups, mediaGroupId) { for (let i = 0; i < groups.length; i++) { const group = groups[i]; if (group.id === mediaGroupId) { @@ -41,7 +36,7 @@ export default class M3U8Parser { } } - static convertAVC1ToAVCOTI (codec) { + static convertAVC1ToAVCOTI(codec) { let avcdata = codec.split('.'); let result; if (avcdata.length > 2) { @@ -54,18 +49,18 @@ export default class M3U8Parser { return result; } - static resolve (url, baseUrl) { + static resolve(url, baseUrl) { return URLToolkit.buildAbsoluteURL(baseUrl, url, { alwaysNormalize: true }); } - static parseMasterPlaylist (string: string, baseurl: string) { + static parseMasterPlaylist(string, baseurl) { // TODO(typescript-level) - let levels: Array = []; + let levels = []; MASTER_PLAYLIST_REGEX.lastIndex = 0; // TODO(typescript-level) - function setCodecs (codecs: Array, level: any) { - ['video', 'audio'].forEach((type: CodecType) => { + function setCodecs(codecs, level) { + ['video', 'audio'].forEach((type) => { const filtered = codecs.filter((codec) => isCodecType(codec, type)); if (filtered.length) { const preferred = filtered.filter((codec) => { @@ -81,10 +76,10 @@ export default class M3U8Parser { level.unknownCodecs = codecs; } - let result: RegExpExecArray | null; + let result; while ((result = MASTER_PLAYLIST_REGEX.exec(string)) != null) { // TODO(typescript-level) - const level: any = {}; + const level = {}; const attrs = level.attrs = new AttrList(result[1]); level.url = M3U8Parser.resolve(result[2], baseurl); @@ -108,15 +103,15 @@ export default class M3U8Parser { return levels; } - static parseMasterPlaylistMedia (string: string, baseurl: string, type: MediaPlaylistType, audioGroups: Array = []): Array { - let result: RegExpExecArray | null; - let medias: Array = []; + static parseMasterPlaylistMedia(string, baseurl, type, audioGroups = []) { + let result; + let medias = []; let id = 0; MASTER_PLAYLIST_MEDIA_REGEX.lastIndex = 0; while ((result = MASTER_PLAYLIST_MEDIA_REGEX.exec(string)) !== null) { const attrs = new AttrList(result[1]); if (attrs.TYPE === type) { - const media: MediaPlaylist = { + const media = { id: id++, groupId: attrs['GROUP-ID'], name: attrs.NAME || attrs.LANGUAGE, @@ -146,16 +141,16 @@ export default class M3U8Parser { return medias; } - static parseLevelPlaylist (string: string, baseurl: string, id: number, type: PlaylistLevelType, levelUrlId: number) { + static parseLevelPlaylist(string, baseurl, id, type, levelUrlId) { let currentSN = 0; let totalduration = 0; let level = new Level(baseurl); let discontinuityCounter = 0; - let prevFrag: Fragment | null = null; - let frag: Fragment | null = new Fragment(); - let result: RegExpExecArray | RegExpMatchArray | null; - let i: number; - let levelkey: LevelKey | undefined; + let prevFrag = null; + let frag = new Fragment(); + let result; + let i; + let levelkey; let firstPdtIndex = null; @@ -168,7 +163,7 @@ export default class M3U8Parser { // avoid sliced strings https://github.com/video-dev/hls.js/issues/939 const title = (' ' + result[2]).slice(1); frag.title = title || null; - frag.tagList.push(title ? [ 'INF', duration, title ] : [ 'INF', duration ]); + frag.tagList.push(title ? ['INF', duration, title] : ['INF', duration]); } else if (result[3]) { // url if (Number.isFinite(frag.duration)) { const sn = currentSN++; @@ -209,7 +204,7 @@ export default class M3U8Parser { } else { result = result[0].match(LEVEL_PLAYLIST_REGEX_SLOW); if (!result) { - logger.warn('No matches on slow regex match for level playlist!'); + console.warn('No matches on slow regex match for level playlist!'); continue; } for (i = 1; i < result.length; i++) { @@ -223,84 +218,84 @@ export default class M3U8Parser { const value2 = (' ' + result[i + 2]).slice(1); switch (result[i]) { - case '#': - frag.tagList.push(value2 ? [ value1, value2 ] : [ value1 ]); - break; - case 'PLAYLIST-TYPE': - level.type = value1.toUpperCase(); - break; - case 'MEDIA-SEQUENCE': - currentSN = level.startSN = parseInt(value1); - break; - case 'TARGETDURATION': - level.targetduration = parseFloat(value1); - break; - case 'VERSION': - level.version = parseInt(value1); - break; - case 'EXTM3U': - break; - case 'ENDLIST': - level.live = false; - break; - case 'DIS': - discontinuityCounter++; - frag.tagList.push(['DIS']); - break; - case 'DISCONTINUITY-SEQ': - discontinuityCounter = parseInt(value1); - break; - case 'KEY': { - // https://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-3.4.4 - const decryptparams = value1; - const keyAttrs = new AttrList(decryptparams); - const decryptmethod = keyAttrs.enumeratedString('METHOD'); - const decrypturi = keyAttrs.URI; - const decryptiv = keyAttrs.hexadecimalInteger('IV'); + case '#': + frag.tagList.push(value2 ? [value1, value2] : [value1]); + break; + case 'PLAYLIST-TYPE': + level.type = value1.toUpperCase(); + break; + case 'MEDIA-SEQUENCE': + currentSN = level.startSN = parseInt(value1); + break; + case 'TARGETDURATION': + level.targetduration = parseFloat(value1); + break; + case 'VERSION': + level.version = parseInt(value1); + break; + case 'EXTM3U': + break; + case 'ENDLIST': + level.live = false; + break; + case 'DIS': + discontinuityCounter++; + frag.tagList.push(['DIS']); + break; + case 'DISCONTINUITY-SEQ': + discontinuityCounter = parseInt(value1); + break; + case 'KEY': { + // https://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-3.4.4 + const decryptparams = value1; + const keyAttrs = new AttrList(decryptparams); + const decryptmethod = keyAttrs.enumeratedString('METHOD'); + const decrypturi = keyAttrs.URI; + const decryptiv = keyAttrs.hexadecimalInteger('IV'); - if (decryptmethod) { - levelkey = new LevelKey(baseurl, decrypturi); - if ((decrypturi) && (['AES-128', 'SAMPLE-AES', 'SAMPLE-AES-CENC'].indexOf(decryptmethod) >= 0)) { - levelkey.method = decryptmethod; - levelkey.key = null; - // Initialization Vector (IV) - levelkey.iv = decryptiv; + if (decryptmethod) { + levelkey = new LevelKey(baseurl, decrypturi); + if ((decrypturi) && (['AES-128', 'SAMPLE-AES', 'SAMPLE-AES-CENC'].indexOf(decryptmethod) >= 0)) { + levelkey.method = decryptmethod; + levelkey.key = null; + // Initialization Vector (IV) + levelkey.iv = decryptiv; + } } + break; } - break; - } - case 'START': { - const startAttrs = new AttrList(value1); - const startTimeOffset = startAttrs.decimalFloatingPoint('TIME-OFFSET'); - // TIME-OFFSET can be 0 - if (Number.isFinite(startTimeOffset)) { - level.startTimeOffset = startTimeOffset; + case 'START': { + const startAttrs = new AttrList(value1); + const startTimeOffset = startAttrs.decimalFloatingPoint('TIME-OFFSET'); + // TIME-OFFSET can be 0 + if (Number.isFinite(startTimeOffset)) { + level.startTimeOffset = startTimeOffset; + } + break; } - break; - } - case 'MAP': { - const mapAttrs = new AttrList(value1); - frag.relurl = mapAttrs.URI; - if (mapAttrs.BYTERANGE) { - frag.setByteRange(mapAttrs.BYTERANGE); + case 'MAP': { + const mapAttrs = new AttrList(value1); + frag.relurl = mapAttrs.URI; + if (mapAttrs.BYTERANGE) { + frag.setByteRange(mapAttrs.BYTERANGE); + } + frag.baseurl = baseurl; + frag.level = id; + frag.type = type; + frag.sn = 'initSegment'; + level.initSegment = frag; + frag = new Fragment(); + frag.rawProgramDateTime = level.initSegment.rawProgramDateTime; + break; } - frag.baseurl = baseurl; - frag.level = id; - frag.type = type; - frag.sn = 'initSegment'; - level.initSegment = frag; - frag = new Fragment(); - frag.rawProgramDateTime = level.initSegment.rawProgramDateTime; - break; - } - default: - logger.warn(`line parsed but not handled: ${result}`); - break; + default: + console.warn(`line parsed but not handled: ${result}`); + break; } } } frag = prevFrag; - // logger.log('found ' + level.fragments.length + ' fragments'); + // console.log('found ' + level.fragments.length + ' fragments'); if (frag && !frag.relurl) { level.fragments.pop(); totalduration -= frag.duration; @@ -316,7 +311,7 @@ export default class M3U8Parser { // if the fragments are TS or MP4, except if we download them :/ // but this is to be able to handle SIDX. if (level.fragments.every((frag) => MP4_REGEX_SUFFIX.test(frag.relurl))) { - logger.warn('MP4 fragments found but no init segment (probably no MAP, incomplete M3U8), trying to fetch SIDX'); + console.warn('MP4 fragments found but no init segment (probably no MAP, incomplete M3U8), trying to fetch SIDX'); frag = new Fragment(); frag.relurl = level.fragments[0].relurl; @@ -347,7 +342,7 @@ export default class M3U8Parser { } } -function backfillProgramDateTimes (fragments, startIndex) { +function backfillProgramDateTimes(fragments, startIndex) { let fragPrev = fragments[startIndex]; for (let i = startIndex - 1; i >= 0; i--) { const frag = fragments[i]; @@ -356,7 +351,7 @@ function backfillProgramDateTimes (fragments, startIndex) { } } -function assignProgramDateTime (frag, prevFrag) { +function assignProgramDateTime(frag, prevFrag) { if (frag.rawProgramDateTime) { frag.programDateTime = Date.parse(frag.rawProgramDateTime); } else if (prevFrag && prevFrag.programDateTime) { diff --git a/cmd/mjpeg-player/hlsjs/observer.js b/cmd/mjpeg-player/hlsjs/observer.js index 33d265eb..82a1100f 100644 --- a/cmd/mjpeg-player/hlsjs/observer.js +++ b/cmd/mjpeg-player/hlsjs/observer.js @@ -1,4 +1,4 @@ -import { EventEmitter } from 'eventemitter3'; +import EventEmitter from '../eventemitter3/index.js'; /** * Simple adapter sub-class of Nodejs-like EventEmitter. @@ -9,7 +9,7 @@ export class Observer extends EventEmitter { * in every call to a handler, which is the purpose of our `trigger` method * extending the standard API. */ - trigger (event: string, ...data: Array): void { + trigger(event, ...data) { this.emit(event, event, ...data); } } diff --git a/cmd/mjpeg-player/hlsjs/types/loader.js b/cmd/mjpeg-player/hlsjs/types/loader.js index f99aa1da..d93c5ed6 100644 --- a/cmd/mjpeg-player/hlsjs/types/loader.js +++ b/cmd/mjpeg-player/hlsjs/types/loader.js @@ -1,132 +1,19 @@ -import Level from '../loader/level'; - -export interface LoaderContext { - // target URL - url: string - // loader response type (arraybuffer or default response type for playlist) - responseType: string - // start byte range offset - rangeStart?: number - // end byte range offset - rangeEnd?: number - // true if onProgress should report partial chunk of loaded content - progressData?: boolean -} - -export interface LoaderConfiguration { - // Max number of load retries - maxRetry: number - // Timeout after which `onTimeOut` callback will be triggered - // (if loading is still not finished after that delay) - timeout: number - // Delay between an I/O error and following connection retry (ms). - // This to avoid spamming the server - retryDelay: number - // max connection retry delay (ms) - maxRetryDelay: number -} - -export interface LoaderResponse { - url: string, - // TODO(jstackhouse): SharedArrayBuffer, es2017 extension to TS - data: string | ArrayBuffer -} - -export interface LoaderStats { - // performance.now() just after load() has been called - trequest: number - // performance.now() of first received byte - tfirst: number - // performance.now() on load complete - tload: number - // performance.now() on parse completion - tparsed: number - // number of loaded bytes - loaded: number - // total number of bytes - total: number -} - -type LoaderOnSuccess < T extends LoaderContext > = ( - response: LoaderResponse, - stats: LoaderStats, - context: T, - networkDetails: any -) => void; - -type LoaderOnProgress < T extends LoaderContext > = ( - stats: LoaderStats, - context: T, - data: string | ArrayBuffer, - networkDetails: any, -) => void; - -type LoaderOnError < T extends LoaderContext > = ( - error: { - // error status code - code: number, - // error description - text: string, - }, - context: T, - networkDetails: any, -) => void; - -type LoaderOnTimeout < T extends LoaderContext > = ( - stats: LoaderStats, - context: T, -) => void; - -export interface LoaderCallbacks{ - onSuccess: LoaderOnSuccess, - onError: LoaderOnError, - onTimeout: LoaderOnTimeout, - onProgress?: LoaderOnProgress, -} - -export interface Loader { - destroy(): void - abort(): void - load( - context: LoaderContext, - config: LoaderConfiguration, - callbacks: LoaderCallbacks, - ): void - - context: T -} - /** - * `type` property values for this loaders' context object - * @enum - * + * @readonly + * @enum {string} */ -export enum PlaylistContextType { - MANIFEST = 'manifest', - LEVEL = 'level', - AUDIO_TRACK = 'audioTrack', - SUBTITLE_TRACK= 'subtitleTrack' +export const PlaylistContextType = { + MANIFEST: 'manifest', + LEVEL: 'level', + AUDIO_TRACK: 'audioTrack', + SUBTITLE_TRACK: 'subtitleTrack' } /** * @enum {string} */ -export enum PlaylistLevelType { - MAIN = 'main', - AUDIO = 'audio', - SUBTITLE = 'subtitle' -} - -export interface PlaylistLoaderContext extends LoaderContext { - loader?: Loader - - type: PlaylistContextType - // the level index to load - level: number | null - // TODO: what is id? - id: number | null - // defines if the loader is handling a sidx request for the playlist - isSidxRequest?: boolean - // internal reprsentation of a parsed m3u8 level playlist - levelDetails?: Level +export const PlaylistLevelType = { + MAIN: 'main', + AUDIO: 'audio', + SUBTITLE: 'subtitle' } diff --git a/cmd/mjpeg-player/hlsjs/utils/codecs.js b/cmd/mjpeg-player/hlsjs/utils/codecs.js index 60ae8a94..4f620a56 100644 --- a/cmd/mjpeg-player/hlsjs/utils/codecs.js +++ b/cmd/mjpeg-player/hlsjs/utils/codecs.js @@ -63,14 +63,12 @@ const sampleEntryCodesISO = { } }; -export type CodecType = 'audio' | 'video'; - -function isCodecType (codec: string, type: CodecType): boolean { +function isCodecType (codec, type) { const typeCodes = sampleEntryCodesISO[type]; return !!typeCodes && typeCodes[codec.slice(0, 4)] === true; } -function isCodecSupportedInMp4 (codec: string, type: CodecType): boolean { +function isCodecSupportedInMp4 (codec, type) { return MediaSource.isTypeSupported(`${type || 'video'}/mp4;codecs="${codec}"`); } From 476c306620073403ab4ba054b769d434c2d77364 Mon Sep 17 00:00:00 2001 From: Trek H Date: Mon, 20 Jan 2020 16:03:35 +1030 Subject: [PATCH 03/15] mjpeg-player: mtsdemuxer functionality change changed MTSDemuxer's 'append' to 'demux' because it is not appending. Updated MTSDemuxer so that it doesn't store the stream after demuxing it, it simply demuxes the data it's given and returns the demuxed data. Any PES packets that were truncated are kept to be tried at the next call to demux. --- cmd/mjpeg-player/hlsjs/mts-demuxer.js | 47 ++++++++++----------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/cmd/mjpeg-player/hlsjs/mts-demuxer.js b/cmd/mjpeg-player/hlsjs/mts-demuxer.js index 22ac59ca..dbbb3393 100644 --- a/cmd/mjpeg-player/hlsjs/mts-demuxer.js +++ b/cmd/mjpeg-player/hlsjs/mts-demuxer.js @@ -36,10 +36,6 @@ class MTSDemuxer { init() { this.pmtParsed = false; this._pmtId = -1; - - this._videoTrack = MTSDemuxer.createTrack('video'); - this._audioTrack = MTSDemuxer.createTrack('audio'); - this._id3Track = MTSDemuxer.createTrack('id3'); } // createTrack creates and returns a track model. @@ -55,15 +51,6 @@ class MTSDemuxer { }; } - // _getTracks returns this MTSDemuxer's tracks. - _getTracks() { - return { - video: this._videoTrack, - audio: this._audioTrack, - id3: this._id3Track - }; - } - // _syncOffset scans the first 'maxScanWindow' bytes and returns an offset to the beginning of the first three MTS packets, // or -1 if three are not found. // A TS fragment should contain at least 3 TS packets, a PAT, a PMT, and one PID, each starting with 0x47. @@ -81,20 +68,20 @@ class MTSDemuxer { return -1; } - append(data) { + demux(data) { let start, len = data.length, pusi, pid, afc, offset, pes, unknownPIDs = false; let pmtParsed = this.pmtParsed, - videoTrack = this._videoTrack, - audioTrack = this._audioTrack, - id3Track = this._id3Track, - videoId = videoTrack.pid, - audioId = audioTrack.pid, - id3Id = id3Track.pid, + videoTrack = MTSDemuxer.createTrack('video'), + audioTrack = MTSDemuxer.createTrack('audio'), + id3Track = MTSDemuxer.createTrack('id3'), + videoId, + audioId, + id3Id, pmtId = this._pmtId, - videoData = videoTrack.pesData, - audioData = audioTrack.pesData, - id3Data = id3Track.pesData, + videoData = this.videoPesData, + audioData = this.audioPesData, + id3Data = this.id3PesData, parsePAT = this._parsePAT, parsePMT = this._parsePMT, parsePES = this._parsePES; @@ -209,27 +196,29 @@ class MTSDemuxer { // Try to parse last PES packets. if (videoData && (pes = parsePES(videoData)) && pes.pts !== undefined) { videoTrack.data.push(pes.data); - videoTrack.pesData = null; + this.videoPesData = null; } else { // Either pesPkts null or PES truncated, keep it for next frag parsing. - videoTrack.pesData = videoData; + this.videoPesData = videoData; } if (audioData && (pes = parsePES(audioData)) && pes.pts !== undefined) { audioTrack.data.push(pes.data); - audioTrack.pesData = null; + this.audioPesData = null; } else { // Either pesPkts null or PES truncated, keep it for next frag parsing. - audioTrack.pesData = audioData; + this.audioPesData = audioData; } if (id3Data && (pes = parsePES(id3Data)) && pes.pts !== undefined) { id3Track.data.push(pes.data); - id3Track.pesData = null; + this.id3PesData = null; } else { // Either pesPkts null or PES truncated, keep it for next frag parsing. - id3Track.pesData = id3Data; + this.id3PesData = id3Data; } + + return videoTrack; } _parsePAT(data, offset) { From 2d71869104012ef304e06c2a701f734bc7faf341 Mon Sep 17 00:00:00 2001 From: Trek H Date: Mon, 20 Jan 2020 17:37:51 +1030 Subject: [PATCH 04/15] mjpeg-player: added level-key ts conversion --- cmd/mjpeg-player/hlsjs/loader/level-key.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/cmd/mjpeg-player/hlsjs/loader/level-key.js b/cmd/mjpeg-player/hlsjs/loader/level-key.js index f5abc95e..b0108cc2 100644 --- a/cmd/mjpeg-player/hlsjs/loader/level-key.js +++ b/cmd/mjpeg-player/hlsjs/loader/level-key.js @@ -1,22 +1,22 @@ -import { buildAbsoluteURL } from 'url-toolkit'; +import URLToolkit from '../../url-toolkit/url-toolkit.js'; export default class LevelKey { - private _uri: string | null = null; + constructor(baseURI, relativeURI) { + this._uri = null; - public baseuri: string; - public reluri: string; - public method: string | null = null; - public key: Uint8Array | null = null; - public iv: Uint8Array | null = null; + this.baseuri; + this.reluri; + this.method = null; + this.key = null; + this.iv = null; - constructor (baseURI: string, relativeURI: string) { this.baseuri = baseURI; this.reluri = relativeURI; } - get uri () { + get uri() { if (!this._uri && this.reluri) { - this._uri = buildAbsoluteURL(this.baseuri, this.reluri, { alwaysNormalize: true }); + this._uri = URLToolkit.buildAbsoluteURL(this.baseuri, this.reluri, { alwaysNormalize: true }); } return this._uri; From 5ad32207b36b83ae00eca009c431518226ccf10f Mon Sep 17 00:00:00 2001 From: Trek H Date: Mon, 20 Jan 2020 19:44:12 +1030 Subject: [PATCH 05/15] mjpeg-player: updated mtsdemuxer header --- cmd/mjpeg-player/hlsjs/mts-demuxer.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cmd/mjpeg-player/hlsjs/mts-demuxer.js b/cmd/mjpeg-player/hlsjs/mts-demuxer.js index dbbb3393..5fe7c351 100644 --- a/cmd/mjpeg-player/hlsjs/mts-demuxer.js +++ b/cmd/mjpeg-player/hlsjs/mts-demuxer.js @@ -1,12 +1,9 @@ /* -NAME - mts-demuxer.js - AUTHOR Trek Hopton LICENSE - This file is Copyright (C) 2019 the Australian Ocean Lab (AusOcean) + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) It is free software: you can redistribute it and/or modify them under the terms of the GNU General Public License as published by the From f062a8456e328c30a52f5910b563533f150e5d6d Mon Sep 17 00:00:00 2001 From: Trek H Date: Mon, 20 Jan 2020 19:46:23 +1030 Subject: [PATCH 06/15] mjpeg-player: added config and events file headers --- cmd/mjpeg-player/hlsjs/config.js | 23 +++++++++++++++++++++++ cmd/mjpeg-player/hlsjs/events.js | 23 +++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/cmd/mjpeg-player/hlsjs/config.js b/cmd/mjpeg-player/hlsjs/config.js index 623d9367..ed548ff9 100644 --- a/cmd/mjpeg-player/hlsjs/config.js +++ b/cmd/mjpeg-player/hlsjs/config.js @@ -1,3 +1,26 @@ +/* +AUTHOR + Trek Hopton + +LICENSE + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. + + For hls.js Copyright notice and license, see LICENSE file. +*/ + import XhrLoader from './utils/xhr-loader.js'; // If possible, keep hlsDefaultConfig shallow diff --git a/cmd/mjpeg-player/hlsjs/events.js b/cmd/mjpeg-player/hlsjs/events.js index aa0cbb06..e71d71c5 100644 --- a/cmd/mjpeg-player/hlsjs/events.js +++ b/cmd/mjpeg-player/hlsjs/events.js @@ -1,3 +1,26 @@ +/* +AUTHOR + Trek Hopton + +LICENSE + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. + + For hls.js Copyright notice and license, see LICENSE file. +*/ + /** * @readonly * @enum {string} From 823334a9830de301d6f1ce7edbe057b3c04c8895 Mon Sep 17 00:00:00 2001 From: Trek H Date: Mon, 20 Jan 2020 19:52:22 +1030 Subject: [PATCH 07/15] mjpeg-player: added file headers --- cmd/mjpeg-player/hlsjs/event-handler.js | 23 +++++++++++++++++ cmd/mjpeg-player/hlsjs/loader/fragment.js | 23 +++++++++++++++++ cmd/mjpeg-player/hlsjs/loader/level-key.js | 23 +++++++++++++++++ cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js | 23 +++++++++++++++++ cmd/mjpeg-player/hlsjs/observer.js | 23 +++++++++++++++++ cmd/mjpeg-player/hlsjs/types/loader.js | 23 +++++++++++++++++ cmd/mjpeg-player/hlsjs/utils/codecs.js | 27 ++++++++++++++++++-- 7 files changed, 163 insertions(+), 2 deletions(-) diff --git a/cmd/mjpeg-player/hlsjs/event-handler.js b/cmd/mjpeg-player/hlsjs/event-handler.js index b7711eb5..24735225 100644 --- a/cmd/mjpeg-player/hlsjs/event-handler.js +++ b/cmd/mjpeg-player/hlsjs/event-handler.js @@ -1,3 +1,26 @@ +/* +AUTHOR + Trek Hopton + +LICENSE + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. + + For hls.js Copyright notice and license, see LICENSE file. +*/ + /* * * All objects in the event handling chain should inherit from this class diff --git a/cmd/mjpeg-player/hlsjs/loader/fragment.js b/cmd/mjpeg-player/hlsjs/loader/fragment.js index 4d2eb659..01fb0509 100644 --- a/cmd/mjpeg-player/hlsjs/loader/fragment.js +++ b/cmd/mjpeg-player/hlsjs/loader/fragment.js @@ -1,3 +1,26 @@ +/* +AUTHOR + Trek Hopton + +LICENSE + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. + + For hls.js Copyright notice and license, see LICENSE file. +*/ + import URLToolkit from '../../url-toolkit/url-toolkit.js'; import LevelKey from './level-key.js'; diff --git a/cmd/mjpeg-player/hlsjs/loader/level-key.js b/cmd/mjpeg-player/hlsjs/loader/level-key.js index b0108cc2..8bfe4331 100644 --- a/cmd/mjpeg-player/hlsjs/loader/level-key.js +++ b/cmd/mjpeg-player/hlsjs/loader/level-key.js @@ -1,3 +1,26 @@ +/* +AUTHOR + Trek Hopton + +LICENSE + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. + + For hls.js Copyright notice and license, see LICENSE file. +*/ + import URLToolkit from '../../url-toolkit/url-toolkit.js'; export default class LevelKey { diff --git a/cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js b/cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js index ab03fab1..8778dc75 100644 --- a/cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js +++ b/cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js @@ -1,3 +1,26 @@ +/* +AUTHOR + Trek Hopton + +LICENSE + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. + + For hls.js Copyright notice and license, see LICENSE file. +*/ + import URLToolkit from '../../url-toolkit/url-toolkit.js'; import Fragment from './fragment.js'; import Level from './level.js'; diff --git a/cmd/mjpeg-player/hlsjs/observer.js b/cmd/mjpeg-player/hlsjs/observer.js index 82a1100f..9f300d23 100644 --- a/cmd/mjpeg-player/hlsjs/observer.js +++ b/cmd/mjpeg-player/hlsjs/observer.js @@ -1,3 +1,26 @@ +/* +AUTHOR + Trek Hopton + +LICENSE + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. + + For hls.js Copyright notice and license, see LICENSE file. +*/ + import EventEmitter from '../eventemitter3/index.js'; /** diff --git a/cmd/mjpeg-player/hlsjs/types/loader.js b/cmd/mjpeg-player/hlsjs/types/loader.js index d93c5ed6..2a23cb1b 100644 --- a/cmd/mjpeg-player/hlsjs/types/loader.js +++ b/cmd/mjpeg-player/hlsjs/types/loader.js @@ -1,3 +1,26 @@ +/* +AUTHOR + Trek Hopton + +LICENSE + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. + + For hls.js Copyright notice and license, see LICENSE file. +*/ + /** * @readonly * @enum {string} diff --git a/cmd/mjpeg-player/hlsjs/utils/codecs.js b/cmd/mjpeg-player/hlsjs/utils/codecs.js index 4f620a56..a9b345aa 100644 --- a/cmd/mjpeg-player/hlsjs/utils/codecs.js +++ b/cmd/mjpeg-player/hlsjs/utils/codecs.js @@ -1,3 +1,26 @@ +/* +AUTHOR + Trek Hopton + +LICENSE + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. + + For hls.js Copyright notice and license, see LICENSE file. +*/ + // from http://mp4ra.org/codecs.html const sampleEntryCodesISO = { audio: { @@ -63,12 +86,12 @@ const sampleEntryCodesISO = { } }; -function isCodecType (codec, type) { +function isCodecType(codec, type) { const typeCodes = sampleEntryCodesISO[type]; return !!typeCodes && typeCodes[codec.slice(0, 4)] === true; } -function isCodecSupportedInMp4 (codec, type) { +function isCodecSupportedInMp4(codec, type) { return MediaSource.isTypeSupported(`${type || 'video'}/mp4;codecs="${codec}"`); } From 6fa4abe78c470f9852a98cd3d63487409abcde91 Mon Sep 17 00:00:00 2001 From: Trek H Date: Mon, 20 Jan 2020 20:05:23 +1030 Subject: [PATCH 08/15] mjpeg-player: playlist loader modification Typescript code has been removed. Functionality that is not being used has been removed. --- .../hlsjs/loader/playlist-loader.js | 505 ++++++------------ 1 file changed, 159 insertions(+), 346 deletions(-) diff --git a/cmd/mjpeg-player/hlsjs/loader/playlist-loader.js b/cmd/mjpeg-player/hlsjs/loader/playlist-loader.js index cd8c6c2b..e34f236b 100644 --- a/cmd/mjpeg-player/hlsjs/loader/playlist-loader.js +++ b/cmd/mjpeg-player/hlsjs/loader/playlist-loader.js @@ -1,71 +1,95 @@ -/** - * PlaylistLoader - delegate for media manifest/playlist loading tasks. Takes care of parsing media to internal data-models. - * - * Once loaded, dispatches events with parsed data-models of manifest/levels/audio/subtitle tracks. - * - * Uses loader(s) set in config to do actual internal loading of resource tasks. - * - * @module - * - */ +/* +AUTHOR + Trek Hopton -import Event from '../events'; -import EventHandler from '../event-handler'; -import { ErrorTypes, ErrorDetails } from '../errors'; -import { logger } from '../utils/logger'; -import { Loader, PlaylistContextType, PlaylistLoaderContext, PlaylistLevelType, LoaderCallbacks, LoaderResponse, LoaderStats, LoaderConfiguration } from '../types/loader'; -import MP4Demuxer from '../demux/mp4demuxer'; -import M3U8Parser from './m3u8-parser'; -import { AudioGroup } from '../types/media-playlist'; +LICENSE + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. + + For hls.js Copyright notice and license, see LICENSE file. +*/ + +import { PlaylistContextType, PlaylistLevelType } from '../types/loader.js'; +import Event from '../events.js'; +import EventHandler from '../event-handler.js'; +import M3U8Parser from './m3u8-parser.js'; const { performance } = window; -/** - * @constructor - */ class PlaylistLoader extends EventHandler { - private loaders: Partial>> = {}; - - /** - * @constructs - * @param {Hls} hls - */ - constructor (hls) { + constructor(hls) { super(hls, Event.MANIFEST_LOADING, Event.LEVEL_LOADING, Event.AUDIO_TRACK_LOADING, Event.SUBTITLE_TRACK_LOADING); + this.hls = hls; + this.loaders = {}; } /** - * @param {PlaylistContextType} type - * @returns {boolean} - */ - static canHaveQualityLevels (type: PlaylistContextType): boolean { + * @param {PlaylistContextType} type + * @returns {boolean} + */ + static canHaveQualityLevels(type) { return (type !== PlaylistContextType.AUDIO_TRACK && type !== PlaylistContextType.SUBTITLE_TRACK); } - /** - * Map context.type to LevelType - * @param {PlaylistLoaderContext} context - * @returns {LevelType} - */ - static mapContextToLevelType (context: PlaylistLoaderContext): PlaylistLevelType { - const { type } = context; - - switch (type) { - case PlaylistContextType.AUDIO_TRACK: - return PlaylistLevelType.AUDIO; - case PlaylistContextType.SUBTITLE_TRACK: - return PlaylistLevelType.SUBTITLE; - default: - return PlaylistLevelType.MAIN; - } + onManifestLoading(data) { + this.load({ + url: data.url, + type: PlaylistContextType.MANIFEST, + level: 0, + id: null, + responseType: 'text' + }); } - static getResponseUrl (response: LoaderResponse, context: PlaylistLoaderContext): string { + // param 1 -> data: { url: string; level: number | null; id: number | null; } + onLevelLoading(data) { + this.load({ + url: data.url, + type: PlaylistContextType.LEVEL, + level: data.level, + id: data.id, + responseType: 'text' + }); + } + + onAudioTrackLoading(data) { + this.load({ + url: data.url, + type: PlaylistContextType.AUDIO_TRACK, + level: null, + id: data.id, + responseType: 'text' + }); + } + + onSubtitleTrackLoading(data) { + this.load({ + url: data.url, + type: PlaylistContextType.SUBTITLE_TRACK, + level: null, + id: data.id, + responseType: 'text' + }); + } + + static getResponseUrl(response, context) { let url = response.url; // responseURL not supported on some browsers (it is used to detect URL redirection) // data-uri mode also not supported (but no need to detect redirection) @@ -76,13 +100,35 @@ class PlaylistLoader extends EventHandler { return url; } + /** + * Map context.type to LevelType + * @param {PlaylistLoaderContext} context + * @returns {LevelType} + */ + static mapContextToLevelType(context) { + const { type } = context; + + switch (type) { + case PlaylistContextType.AUDIO_TRACK: + return PlaylistLevelType.AUDIO; + case PlaylistContextType.SUBTITLE_TRACK: + return PlaylistLevelType.SUBTITLE; + default: + return PlaylistLevelType.MAIN; + } + } + + getInternalLoader(context) { + return this.loaders[context.type]; + } + /** * Returns defaults or configured loader-type overloads (pLoader and loader config params) * Default loader is XHRLoader (see utils) * @param {PlaylistLoaderContext} context * @returns {Loader} or other compatible configured overload */ - createInternalLoader (context: PlaylistLoaderContext): Loader { + createInternalLoader(context) { const config = this.hls.config; const PLoader = config.pLoader; const Loader = config.loader; @@ -98,146 +144,77 @@ class PlaylistLoader extends EventHandler { return loader; } - getInternalLoader (context: PlaylistLoaderContext): Loader | undefined { - return this.loaders[context.type]; - } - - resetInternalLoader (contextType: PlaylistContextType) { + resetInternalLoader(contextType) { if (this.loaders[contextType]) { delete this.loaders[contextType]; } } - /** - * Call `destroy` on all internal loader instances mapped (one per context type) - */ - destroyInternalLoaders () { - for (let contextType in this.loaders) { - let loader = this.loaders[contextType]; - if (loader) { - loader.destroy(); - } - - this.resetInternalLoader(contextType as PlaylistContextType); - } - } - - destroy () { - this.destroyInternalLoaders(); - - super.destroy(); - } - - onManifestLoading (data: { url: string; }) { - this.load({ - url: data.url, - type: PlaylistContextType.MANIFEST, - level: 0, - id: null, - responseType: 'text' - }); - } - - onLevelLoading (data: { url: string; level: number | null; id: number | null; }) { - this.load({ - url: data.url, - type: PlaylistContextType.LEVEL, - level: data.level, - id: data.id, - responseType: 'text' - }); - } - - onAudioTrackLoading (data: { url: string; id: number | null; }) { - this.load({ - url: data.url, - type: PlaylistContextType.AUDIO_TRACK, - level: null, - id: data.id, - responseType: 'text' - }); - } - - onSubtitleTrackLoading (data: { url: string; id: number | null; }) { - this.load({ - url: data.url, - type: PlaylistContextType.SUBTITLE_TRACK, - level: null, - id: data.id, - responseType: 'text' - }); - } - - load (context: PlaylistLoaderContext): boolean { + load(context) { const config = this.hls.config; - logger.debug(`Loading playlist of type ${context.type}, level: ${context.level}, id: ${context.id}`); - // Check if a loader for this context already exists let loader = this.getInternalLoader(context); if (loader) { const loaderContext = loader.context; if (loaderContext && loaderContext.url === context.url) { // same URL can't overlap - logger.trace('playlist request ongoing'); return false; } else { - logger.warn(`aborting previous loader for type: ${context.type}`); loader.abort(); } } - let maxRetry: number; - let timeout: number; - let retryDelay: number; - let maxRetryDelay: number; + let maxRetry; + let timeout; + let retryDelay; + let maxRetryDelay; // apply different configs for retries depending on // context (manifest, level, audio/subs playlist) switch (context.type) { - case PlaylistContextType.MANIFEST: - maxRetry = config.manifestLoadingMaxRetry; - timeout = config.manifestLoadingTimeOut; - retryDelay = config.manifestLoadingRetryDelay; - maxRetryDelay = config.manifestLoadingMaxRetryTimeout; - break; - case PlaylistContextType.LEVEL: - // Disable internal loader retry logic, since we are managing retries in Level Controller - maxRetry = 0; - maxRetryDelay = 0; - retryDelay = 0; - timeout = config.levelLoadingTimeOut; - // TODO Introduce retry settings for audio-track and subtitle-track, it should not use level retry config - break; - default: - maxRetry = config.levelLoadingMaxRetry; - timeout = config.levelLoadingTimeOut; - retryDelay = config.levelLoadingRetryDelay; - maxRetryDelay = config.levelLoadingMaxRetryTimeout; - break; + case PlaylistContextType.MANIFEST: + maxRetry = config.manifestLoadingMaxRetry; + timeout = config.manifestLoadingTimeOut; + retryDelay = config.manifestLoadingRetryDelay; + maxRetryDelay = config.manifestLoadingMaxRetryTimeout; + break; + case PlaylistContextType.LEVEL: + // Disable internal loader retry logic, since we are managing retries in Level Controller + maxRetry = 0; + maxRetryDelay = 0; + retryDelay = 0; + timeout = config.levelLoadingTimeOut; + // TODO Introduce retry settings for audio-track and subtitle-track, it should not use level retry config + break; + default: + maxRetry = config.levelLoadingMaxRetry; + timeout = config.levelLoadingTimeOut; + retryDelay = config.levelLoadingRetryDelay; + maxRetryDelay = config.levelLoadingMaxRetryTimeout; + break; } loader = this.createInternalLoader(context); - const loaderConfig: LoaderConfiguration = { + const loaderConfig = { timeout, maxRetry, retryDelay, maxRetryDelay }; - const loaderCallbacks: LoaderCallbacks = { + const loaderCallbacks = { onSuccess: this.loadsuccess.bind(this), onError: this.loaderror.bind(this), onTimeout: this.loadtimeout.bind(this) }; - logger.debug(`Calling internal loader delegate for URL: ${context.url}`); loader.load(context, loaderConfig, loaderCallbacks); return true; } - loadsuccess (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown = null) { + loadsuccess(response, stats, context, networkDetails = null) { if (context.isSidxRequest) { this._handleSidxRequest(response, context); this._handlePlaylistLoaded(response, stats, context, networkDetails); @@ -252,11 +229,10 @@ class PlaylistLoader extends EventHandler { const string = response.data; stats.tload = performance.now(); - // stats.mtime = new Date(target.getResponseHeader('Last-Modified')); // Validate if it is an M3U8 at all if (string.indexOf('#EXTM3U') !== 0) { - this._handleManifestParsingError(response, context, 'no EXTM3U delimiter', networkDetails); + console.error("no EXTM3U delimiter"); return; } @@ -264,77 +240,21 @@ class PlaylistLoader extends EventHandler { if (string.indexOf('#EXTINF:') > 0 || string.indexOf('#EXT-X-TARGETDURATION:') > 0) { this._handleTrackOrLevelPlaylist(response, stats, context, networkDetails); } else { - this._handleMasterPlaylist(response, stats, context, networkDetails); - } - } - - loaderror (response: LoaderResponse, context: PlaylistLoaderContext, networkDetails = null) { - this._handleNetworkError(context, networkDetails, false, response); - } - - loadtimeout (stats: LoaderStats, context: PlaylistLoaderContext, networkDetails = null) { - this._handleNetworkError(context, networkDetails, true); - } - - // TODO(typescript-config): networkDetails can currently be a XHR or Fetch impl, - // but with custom loaders it could be generic investigate this further when config is typed - _handleMasterPlaylist (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown) { - const hls = this.hls; - const string = response.data as string; - - const url = PlaylistLoader.getResponseUrl(response, context); - const levels = M3U8Parser.parseMasterPlaylist(string, url); - if (!levels.length) { - this._handleManifestParsingError(response, context, 'no level found in manifest', networkDetails); - return; + console.log("handling of master playlists is not implemented"); + // this._handleMasterPlaylist(response, stats, context, networkDetails); } - // multi level playlist, parse level info - const audioGroups: Array = levels.map(level => ({ - id: level.attrs.AUDIO, - codec: level.audioCodec - })); - - const audioTracks = M3U8Parser.parseMasterPlaylistMedia(string, url, 'AUDIO', audioGroups); - const subtitles = M3U8Parser.parseMasterPlaylistMedia(string, url, 'SUBTITLES'); - - if (audioTracks.length) { - // check if we have found an audio track embedded in main playlist (audio track without URI attribute) - let embeddedAudioFound = false; - audioTracks.forEach(audioTrack => { - if (!audioTrack.url) { - embeddedAudioFound = true; - } - }); - - // if no embedded audio track defined, but audio codec signaled in quality level, - // we need to signal this main audio track this could happen with playlists with - // alt audio rendition in which quality levels (main) - // contains both audio+video. but with mixed audio track not signaled - if (embeddedAudioFound === false && levels[0].audioCodec && !levels[0].attrs.AUDIO) { - logger.log('audio codec signaled in quality level, but no embedded audio track signaled, create one'); - audioTracks.unshift({ - type: 'main', - name: 'main', - default: false, - autoselect: false, - forced: false, - id: -1 - }); - } - } - - hls.trigger(Event.MANIFEST_LOADED, { - levels, - audioTracks, - subtitles, - url, - stats, - networkDetails - }); } - _handleTrackOrLevelPlaylist (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown) { + loaderror(response, context, networkDetails = null) { + console.error("network error while loading", response); + } + + loadtimeout(stats, context, networkDetails = null) { + console.error("network timeout while loading", stats); + } + + _handleTrackOrLevelPlaylist(response, stats, context, networkDetails) { const hls = this.hls; const { id, level, type } = context; @@ -342,15 +262,15 @@ class PlaylistLoader extends EventHandler { const url = PlaylistLoader.getResponseUrl(response, context); // if the values are null, they will result in the else conditional - const levelUrlId = Number.isFinite(id as number) ? id as number : 0; - const levelId = Number.isFinite(level as number) ? level as number : levelUrlId; + const levelUrlId = Number.isFinite(id) ? id : 0; + const levelId = Number.isFinite(level) ? level : levelUrlId; const levelType = PlaylistLoader.mapContextToLevelType(context); - const levelDetails = M3U8Parser.parseLevelPlaylist(response.data as string, url, levelId, levelType, levelUrlId); + const levelDetails = M3U8Parser.parseLevelPlaylist(response.data, url, levelId, levelType, levelUrlId); // set stats on level structure // TODO(jstackhouse): why? mixing concerns, is it just treated as value bag? - (levelDetails as any).tload = stats.tload; + (levelDetails).tload = stats.tload; // We have done our first request (Manifest-type) and receive // not a master playlist but a chunk-list (track/level) @@ -374,124 +294,17 @@ class PlaylistLoader extends EventHandler { // save parsing time stats.tparsed = performance.now(); - // in case we need SIDX ranges - // return early after calling load for - // the SIDX box. - if (levelDetails.needSidxRanges) { - const sidxUrl = levelDetails.initSegment.url; - this.load({ - url: sidxUrl, - isSidxRequest: true, - type, - level, - levelDetails, - id, - rangeStart: 0, - rangeEnd: 2048, - responseType: 'arraybuffer' - }); - return; - } - // extend the context with the new levelDetails property context.levelDetails = levelDetails; this._handlePlaylistLoaded(response, stats, context, networkDetails); } - _handleSidxRequest (response: LoaderResponse, context: PlaylistLoaderContext) { - if (typeof response.data === 'string') { - throw new Error('sidx request must be made with responseType of array buffer'); - } - - const sidxInfo = MP4Demuxer.parseSegmentIndex(new Uint8Array(response.data)); - // if provided fragment does not contain sidx, early return - if (!sidxInfo) { - return; - } - const sidxReferences = sidxInfo.references; - const levelDetails = context.levelDetails; - sidxReferences.forEach((segmentRef, index) => { - const segRefInfo = segmentRef.info; - if (!levelDetails) { - return; - } - const frag = levelDetails.fragments[index]; - if (frag.byteRange.length === 0) { - frag.setByteRange(String(1 + segRefInfo.end - segRefInfo.start) + '@' + String(segRefInfo.start)); - } - }); - - if (levelDetails) { - levelDetails.initSegment.setByteRange(String(sidxInfo.moovEndOffset) + '@0'); - } - } - - _handleManifestParsingError (response: LoaderResponse, context: PlaylistLoaderContext, reason: string, networkDetails: unknown) { - this.hls.trigger(Event.ERROR, { - type: ErrorTypes.NETWORK_ERROR, - details: ErrorDetails.MANIFEST_PARSING_ERROR, - fatal: true, - url: response.url, - reason, - networkDetails - }); - } - - _handleNetworkError (context: PlaylistLoaderContext, networkDetails: unknown, timeout: boolean = false, response: LoaderResponse | null = null) { - logger.info(`A network error occured while loading a ${context.type}-type playlist`); - - let details; - let fatal; - - const loader = this.getInternalLoader(context); - - switch (context.type) { - case PlaylistContextType.MANIFEST: - details = (timeout ? ErrorDetails.MANIFEST_LOAD_TIMEOUT : ErrorDetails.MANIFEST_LOAD_ERROR); - fatal = true; - break; - case PlaylistContextType.LEVEL: - details = (timeout ? ErrorDetails.LEVEL_LOAD_TIMEOUT : ErrorDetails.LEVEL_LOAD_ERROR); - fatal = false; - break; - case PlaylistContextType.AUDIO_TRACK: - details = (timeout ? ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT : ErrorDetails.AUDIO_TRACK_LOAD_ERROR); - fatal = false; - break; - default: - // details = ...? - fatal = false; - } - - if (loader) { - loader.abort(); - this.resetInternalLoader(context.type); - } - - // TODO(typescript-events): when error events are handled, type this - let errorData: any = { - type: ErrorTypes.NETWORK_ERROR, - details, - fatal, - url: context.url, - loader, - context, - networkDetails - }; - - if (response) { - errorData.response = response; - } - - this.hls.trigger(Event.ERROR, errorData); - } - - _handlePlaylistLoaded (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown) { + _handlePlaylistLoaded(response, stats, context, networkDetails) { const { type, level, id, levelDetails } = context; if (!levelDetails || !levelDetails.targetduration) { - this._handleManifestParsingError(response, context, 'invalid target duration', networkDetails); + console.error("manifest parsing error"); return; } @@ -506,22 +319,22 @@ class PlaylistLoader extends EventHandler { }); } else { switch (type) { - case PlaylistContextType.AUDIO_TRACK: - this.hls.trigger(Event.AUDIO_TRACK_LOADED, { - details: levelDetails, - id, - stats, - networkDetails - }); - break; - case PlaylistContextType.SUBTITLE_TRACK: - this.hls.trigger(Event.SUBTITLE_TRACK_LOADED, { - details: levelDetails, - id, - stats, - networkDetails - }); - break; + case PlaylistContextType.AUDIO_TRACK: + this.hls.trigger(Event.AUDIO_TRACK_LOADED, { + details: levelDetails, + id, + stats, + networkDetails + }); + break; + case PlaylistContextType.SUBTITLE_TRACK: + this.hls.trigger(Event.SUBTITLE_TRACK_LOADED, { + details: levelDetails, + id, + stats, + networkDetails + }); + break; } } } From 8e718386f230498240d4dcd3243df2f7a697ffe9 Mon Sep 17 00:00:00 2001 From: Trek H Date: Mon, 20 Jan 2020 20:20:03 +1030 Subject: [PATCH 09/15] mjpeg-player: minor changes to xhr-loader and frag-loader Changes include: using console for logger, import statements, formatting. --- .../hlsjs/loader/fragment-loader.js | 45 +++++++++++----- cmd/mjpeg-player/hlsjs/utils/xhr-loader.js | 52 +++++++++++++------ 2 files changed, 70 insertions(+), 27 deletions(-) diff --git a/cmd/mjpeg-player/hlsjs/loader/fragment-loader.js b/cmd/mjpeg-player/hlsjs/loader/fragment-loader.js index ac5f3d51..b7e1f9e1 100644 --- a/cmd/mjpeg-player/hlsjs/loader/fragment-loader.js +++ b/cmd/mjpeg-player/hlsjs/loader/fragment-loader.js @@ -1,19 +1,40 @@ +/* +AUTHOR + Trek Hopton + +LICENSE + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. + + For hls.js Copyright notice and license, see LICENSE file. +*/ + /* * Fragment Loader */ -import Event from '../events'; -import EventHandler from '../event-handler'; -import { ErrorTypes, ErrorDetails } from '../errors'; -import { logger } from '../utils/logger'; +import Event from '../events.js'; +import EventHandler from '../event-handler.js'; class FragmentLoader extends EventHandler { - constructor (hls) { + constructor(hls) { super(hls, Event.FRAG_LOADING); this.loaders = {}; } - destroy () { + destroy() { let loaders = this.loaders; for (let loaderName in loaders) { let loader = loaders[loaderName]; @@ -26,7 +47,7 @@ class FragmentLoader extends EventHandler { super.destroy(); } - onFragLoading (data) { + onFragLoading(data) { const frag = data.frag, type = frag.type, loaders = this.loaders, @@ -39,7 +60,7 @@ class FragmentLoader extends EventHandler { let loader = loaders[type]; if (loader) { - logger.warn(`abort previous fragment loader for type: ${type}`); + console.warn(`abort previous fragment loader for type: ${type}`); loader.abort(); } @@ -75,7 +96,7 @@ class FragmentLoader extends EventHandler { loader.load(loaderContext, loaderConfig, loaderCallbacks); } - loadsuccess (response, stats, context, networkDetails = null) { + loadsuccess(response, stats, context, networkDetails = null) { let payload = response.data, frag = context.frag; // detach fragment loader on load success frag.loader = undefined; @@ -83,7 +104,7 @@ class FragmentLoader extends EventHandler { this.hls.trigger(Event.FRAG_LOADED, { payload: payload, frag: frag, stats: stats, networkDetails: networkDetails }); } - loaderror (response, context, networkDetails = null) { + loaderror(response, context, networkDetails = null) { const frag = context.frag; let loader = frag.loader; if (loader) { @@ -94,7 +115,7 @@ class FragmentLoader extends EventHandler { this.hls.trigger(Event.ERROR, { type: ErrorTypes.NETWORK_ERROR, details: ErrorDetails.FRAG_LOAD_ERROR, fatal: false, frag: context.frag, response: response, networkDetails: networkDetails }); } - loadtimeout (stats, context, networkDetails = null) { + loadtimeout(stats, context, networkDetails = null) { const frag = context.frag; let loader = frag.loader; if (loader) { @@ -106,7 +127,7 @@ class FragmentLoader extends EventHandler { } // data will be used for progressive parsing - loadprogress (stats, context, data, networkDetails = null) { // jshint ignore:line + loadprogress(stats, context, data, networkDetails = null) { // jshint ignore:line let frag = context.frag; frag.loaded = stats.loaded; this.hls.trigger(Event.FRAG_LOAD_PROGRESS, { frag: frag, stats: stats, networkDetails: networkDetails }); diff --git a/cmd/mjpeg-player/hlsjs/utils/xhr-loader.js b/cmd/mjpeg-player/hlsjs/utils/xhr-loader.js index 10ff29c7..67280a87 100644 --- a/cmd/mjpeg-player/hlsjs/utils/xhr-loader.js +++ b/cmd/mjpeg-player/hlsjs/utils/xhr-loader.js @@ -1,24 +1,45 @@ -/** - * XHR based logger +/* +AUTHOR + Trek Hopton + +LICENSE + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. + + For hls.js Copyright notice and license, see LICENSE file. */ -import { logger } from '../utils/logger'; +/** + * XHR based loader +*/ const { performance, XMLHttpRequest } = window; class XhrLoader { - constructor (config) { + constructor(config) { if (config && config.xhrSetup) { this.xhrSetup = config.xhrSetup; } } - destroy () { + destroy() { this.abort(); this.loader = null; } - abort () { + abort() { let loader = this.loader; if (loader && loader.readyState !== 4) { this.stats.aborted = true; @@ -31,7 +52,7 @@ class XhrLoader { this.retryTimeout = null; } - load (context, config, callbacks) { + load(context, config, callbacks) { this.context = context; this.config = config; this.callbacks = callbacks; @@ -40,10 +61,11 @@ class XhrLoader { this.loadInternal(); } - loadInternal () { + loadInternal() { let xhr, context = this.context; xhr = this.loader = new XMLHttpRequest(); - + window.console.log("load internal xhr: " + context.url); + let stats = this.stats; stats.tfirst = 0; stats.loaded = 0; @@ -82,7 +104,7 @@ class XhrLoader { xhr.send(); } - readystatechange (event) { + readystatechange(event) { let xhr = event.currentTarget, readyState = xhr.readyState, stats = this.stats, @@ -121,11 +143,11 @@ class XhrLoader { } else { // if max nb of retries reached or if http status between 400 and 499 (such error cannot be recovered, retrying is useless), return error if (stats.retry >= config.maxRetry || (status >= 400 && status < 499)) { - logger.error(`${status} while loading ${context.url}`); + console.error(`${status} while loading ${context.url}`); this.callbacks.onError({ code: status, text: xhr.statusText }, context, xhr); } else { // retry - logger.warn(`${status} while loading ${context.url}, retrying in ${this.retryDelay}...`); + console.warn(`${status} while loading ${context.url}, retrying in ${this.retryDelay}...`); // aborts and resets internal state this.destroy(); // schedule retry @@ -142,12 +164,12 @@ class XhrLoader { } } - loadtimeout () { - logger.warn(`timeout while loading ${this.context.url}`); + loadtimeout() { + console.warn(`timeout while loading ${this.context.url}`); this.callbacks.onTimeout(this.stats, this.context, null); } - loadprogress (event) { + loadprogress(event) { let xhr = event.currentTarget, stats = this.stats; From ceac1c9e32f28eba376a0c7b341f7a1e7090e79a Mon Sep 17 00:00:00 2001 From: Trek H Date: Mon, 20 Jan 2020 20:31:23 +1030 Subject: [PATCH 10/15] mjpeg-player: reduce and simplify hlsjs code hls.js and stream-controller were both very large ariginally so as to make things simpler, they have been reduced in size and complexity and now just perform the functionality that we need. --- .../hlsjs/controller/stream-controller.js | 1326 +---------------- cmd/mjpeg-player/hlsjs/hls.js | 694 +-------- 2 files changed, 84 insertions(+), 1936 deletions(-) diff --git a/cmd/mjpeg-player/hlsjs/controller/stream-controller.js b/cmd/mjpeg-player/hlsjs/controller/stream-controller.js index 092a55bc..31fd0828 100644 --- a/cmd/mjpeg-player/hlsjs/controller/stream-controller.js +++ b/cmd/mjpeg-player/hlsjs/controller/stream-controller.js @@ -1,801 +1,93 @@ +/* +AUTHOR + Trek Hopton + +LICENSE + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. + + For hls.js Copyright notice and license, see LICENSE file. +*/ + /* * Stream Controller */ -import BinarySearch from '../utils/binary-search'; -import { BufferHelper } from '../utils/buffer-helper'; -import Demuxer from '../demux/demuxer'; -import Event from '../events'; -import { FragmentState } from './fragment-tracker'; -import { ElementaryStreamTypes } from '../loader/fragment'; -import { PlaylistLevelType } from '../types/loader'; -import * as LevelHelper from './level-helper'; -import TimeRanges from '../utils/time-ranges'; -import { ErrorDetails } from '../errors'; -import { logger } from '../utils/logger'; -import { alignStream } from '../utils/discontinuities'; -import { findFragmentByPDT, findFragmentByPTS } from './fragment-finders'; -import GapController from './gap-controller'; -import BaseStreamController, { State } from './base-stream-controller'; +import Event from '../events.js'; +import EventHandler from '../event-handler.js'; -const TICK_INTERVAL = 100; // how often to tick in ms - -class StreamController extends BaseStreamController { - constructor (hls, fragmentTracker) { +class StreamController extends EventHandler { + constructor(hls) { super(hls, - Event.MEDIA_ATTACHED, - Event.MEDIA_DETACHING, - Event.MANIFEST_LOADING, - Event.MANIFEST_PARSED, Event.LEVEL_LOADED, - Event.KEY_LOADED, - Event.FRAG_LOADED, - Event.FRAG_LOAD_EMERGENCY_ABORTED, - Event.FRAG_PARSING_INIT_SEGMENT, - Event.FRAG_PARSING_DATA, - Event.FRAG_PARSED, - Event.ERROR, - Event.AUDIO_TRACK_SWITCHING, - Event.AUDIO_TRACK_SWITCHED, - Event.BUFFER_CREATED, - Event.BUFFER_APPENDED, - Event.BUFFER_FLUSHED); - - this.fragmentTracker = fragmentTracker; + Event.FRAG_LOADED); + this.hls = hls; this.config = hls.config; this.audioCodecSwap = false; - this._state = State.STOPPED; this.stallReported = false; this.gapController = null; + this.currentFrag = 0; } - startLoad (startPosition) { - if (this.levels) { - let lastCurrentTime = this.lastCurrentTime, hls = this.hls; - this.stopLoad(); - this.setInterval(TICK_INTERVAL); - this.level = -1; - this.fragLoadError = 0; - if (!this.startFragRequested) { - // determine load level - let startLevel = hls.startLevel; - if (startLevel === -1) { - // -1 : guess start Level by doing a bitrate test by loading first fragment of lowest quality level - startLevel = 0; - this.bitrateTest = true; - } - // set new level to playlist loader : this will trigger start level load - // hls.nextLoadLevel remains until it is set to a new value or until a new frag is successfully loaded - this.level = hls.nextLoadLevel = startLevel; - this.loadedmetadata = false; - } - // if startPosition undefined but lastCurrentTime set, set startPosition to last currentTime - if (lastCurrentTime > 0 && startPosition === -1) { - logger.log(`override startPosition with lastCurrentTime @${lastCurrentTime.toFixed(3)}`); - startPosition = lastCurrentTime; - } - this.state = State.IDLE; - this.nextLoadPosition = this.startPosition = this.lastCurrentTime = startPosition; - this.tick(); - } else { - this.forceStartLoad = true; - this.state = State.STOPPED; - } + _fetchPayloadOrEos(levelDetails) { + this.fragments = levelDetails.fragments; + this._loadFragment(); } - stopLoad () { - this.forceStartLoad = false; - super.stopLoad(); - } - - doTick () { - switch (this.state) { - case State.BUFFER_FLUSHING: - // in buffer flushing state, reset fragLoadError counter - this.fragLoadError = 0; - break; - case State.IDLE: - this._doTickIdle(); - break; - case State.WAITING_LEVEL: - var level = this.levels[this.level]; - // check if playlist is already loaded - if (level && level.details) { - this.state = State.IDLE; - } - - break; - case State.FRAG_LOADING_WAITING_RETRY: - var now = window.performance.now(); - var retryDate = this.retryDate; - // if current time is gt than retryDate, or if media seeking let's switch to IDLE state to retry loading - if (!retryDate || (now >= retryDate) || (this.media && this.media.seeking)) { - logger.log('mediaController: retryDate reached, switch back to IDLE state'); - this.state = State.IDLE; - } - break; - case State.ERROR: - case State.STOPPED: - case State.FRAG_LOADING: - case State.PARSING: - case State.PARSED: - case State.ENDED: - break; - default: - break; - } - // check buffer - this._checkBuffer(); - // check/update current fragment - this._checkFragmentChanged(); - } - - // Ironically the "idle" state is the on we do the most logic in it seems .... - // NOTE: Maybe we could rather schedule a check for buffer length after half of the currently - // played segment, or on pause/play/seek instead of naively checking every 100ms? - _doTickIdle () { - const hls = this.hls, - config = hls.config, - media = this.media; - - // if start level not parsed yet OR - // if video not attached AND start fragment already requested OR start frag prefetch disable - // exit loop, as we either need more info (level not parsed) or we need media to be attached to load new fragment - if (this.levelLastLoaded === undefined || ( - !media && (this.startFragRequested || !config.startFragPrefetch))) { + _loadFragment() { + let fragLen = this.fragments.length; + if (this.currentFrag >= fragLen) { return; } - - // if we have not yet loaded any fragment, start loading from start position - let pos; - if (this.loadedmetadata) { - pos = media.currentTime; - } else { - pos = this.nextLoadPosition; - } - - // determine next load level - let level = hls.nextLoadLevel, - levelInfo = this.levels[level]; - - if (!levelInfo) { - return; - } - - let levelBitrate = levelInfo.bitrate, - maxBufLen; - - // compute max Buffer Length that we could get from this load level, based on level bitrate. - if (levelBitrate) { - maxBufLen = Math.max(8 * config.maxBufferSize / levelBitrate, config.maxBufferLength); - } else { - maxBufLen = config.maxBufferLength; - } - - maxBufLen = Math.min(maxBufLen, config.maxMaxBufferLength); - - // determine next candidate fragment to be loaded, based on current position and end of buffer position - // ensure up to `config.maxMaxBufferLength` of buffer upfront - - const bufferInfo = BufferHelper.bufferInfo(this.mediaBuffer ? this.mediaBuffer : media, pos, config.maxBufferHole), - bufferLen = bufferInfo.len; - // Stay idle if we are still with buffer margins - if (bufferLen >= maxBufLen) { - return; - } - - // if buffer length is less than maxBufLen try to load a new fragment ... - logger.trace(`buffer length of ${bufferLen.toFixed(3)} is below max of ${maxBufLen.toFixed(3)}. checking for more payload ...`); - - // set next load level : this will trigger a playlist load if needed - this.level = hls.nextLoadLevel = level; - - const levelDetails = levelInfo.details; - // if level info not retrieved yet, switch state and wait for level retrieval - // if live playlist, ensure that new playlist has been refreshed to avoid loading/try to load - // a useless and outdated fragment (that might even introduce load error if it is already out of the live playlist) - if (!levelDetails || (levelDetails.live && this.levelLastLoaded !== level)) { - this.state = State.WAITING_LEVEL; - return; - } - - if (this._streamEnded(bufferInfo, levelDetails)) { - const data = {}; - if (this.altAudio) { - data.type = 'video'; - } - - this.hls.trigger(Event.BUFFER_EOS, data); - this.state = State.ENDED; - return; - } - // if we have the levelDetails for the selected variant, lets continue enrichen our stream (load keys/fragments or trigger EOS, etc..) - this._fetchPayloadOrEos(pos, bufferInfo, levelDetails); + this.hls.trigger(Event.FRAG_LOADING, { frag: this.fragments[this.currentFrag++] }); } - _fetchPayloadOrEos (pos, bufferInfo, levelDetails) { - const fragPrevious = this.fragPrevious, - level = this.level, - fragments = levelDetails.fragments, - fragLen = fragments.length; - - // empty playlist - if (fragLen === 0) { - return; - } - - // find fragment index, contiguous with end of buffer position - let start = fragments[0].start, - end = fragments[fragLen - 1].start + fragments[fragLen - 1].duration, - bufferEnd = bufferInfo.end, - frag; - - if (levelDetails.initSegment && !levelDetails.initSegment.data) { - frag = levelDetails.initSegment; - } else { - // in case of live playlist we need to ensure that requested position is not located before playlist start - if (levelDetails.live) { - let initialLiveManifestSize = this.config.initialLiveManifestSize; - if (fragLen < initialLiveManifestSize) { - logger.warn(`Can not start playback of a level, reason: not enough fragments ${fragLen} < ${initialLiveManifestSize}`); - return; - } - - frag = this._ensureFragmentAtLivePoint(levelDetails, bufferEnd, start, end, fragPrevious, fragments, fragLen); - // if it explicitely returns null don't load any fragment and exit function now - if (frag === null) { - return; - } - } else { - // VoD playlist: if bufferEnd before start of playlist, load first fragment - if (bufferEnd < start) { - frag = fragments[0]; - } - } - } - if (!frag) { - frag = this._findFragment(start, fragPrevious, fragLen, fragments, bufferEnd, end, levelDetails); - } - - if (frag) { - if (frag.encrypted) { - logger.log(`Loading key for ${frag.sn} of [${levelDetails.startSN} ,${levelDetails.endSN}],level ${level}`); - this._loadKey(frag); - } else { - logger.log(`Loading ${frag.sn} of [${levelDetails.startSN} ,${levelDetails.endSN}],level ${level}, currentTime:${pos.toFixed(3)},bufferEnd:${bufferEnd.toFixed(3)}`); - this._loadFragment(frag); - } - } - } - - _ensureFragmentAtLivePoint (levelDetails, bufferEnd, start, end, fragPrevious, fragments, fragLen) { - const config = this.hls.config, media = this.media; - - let frag; - - // check if requested position is within seekable boundaries : - // logger.log(`start/pos/bufEnd/seeking:${start.toFixed(3)}/${pos.toFixed(3)}/${bufferEnd.toFixed(3)}/${this.media.seeking}`); - let maxLatency = config.liveMaxLatencyDuration !== undefined ? config.liveMaxLatencyDuration : config.liveMaxLatencyDurationCount * levelDetails.targetduration; - - if (bufferEnd < Math.max(start - config.maxFragLookUpTolerance, end - maxLatency)) { - let liveSyncPosition = this.liveSyncPosition = this.computeLivePosition(start, levelDetails); - logger.log(`buffer end: ${bufferEnd.toFixed(3)} is located too far from the end of live sliding playlist, reset currentTime to : ${liveSyncPosition.toFixed(3)}`); - bufferEnd = liveSyncPosition; - if (media && media.readyState && media.duration > liveSyncPosition) { - media.currentTime = liveSyncPosition; - } - - this.nextLoadPosition = liveSyncPosition; - } - - // if end of buffer greater than live edge, don't load any fragment - // this could happen if live playlist intermittently slides in the past. - // level 1 loaded [182580161,182580167] - // level 1 loaded [182580162,182580169] - // Loading 182580168 of [182580162 ,182580169],level 1 .. - // Loading 182580169 of [182580162 ,182580169],level 1 .. - // level 1 loaded [182580162,182580168] <============= here we should have bufferEnd > end. in that case break to avoid reloading 182580168 - // level 1 loaded [182580164,182580171] - // - // don't return null in case media not loaded yet (readystate === 0) - if (levelDetails.PTSKnown && bufferEnd > end && media && media.readyState) { - return null; - } - - if (this.startFragRequested && !levelDetails.PTSKnown) { - /* we are switching level on live playlist, but we don't have any PTS info for that quality level ... - try to load frag matching with next SN. - even if SN are not synchronized between playlists, loading this frag will help us - compute playlist sliding and find the right one after in case it was not the right consecutive one */ - if (fragPrevious) { - if (levelDetails.hasProgramDateTime) { - // Relies on PDT in order to switch bitrates (Support EXT-X-DISCONTINUITY without EXT-X-DISCONTINUITY-SEQUENCE) - logger.log(`live playlist, switching playlist, load frag with same PDT: ${fragPrevious.programDateTime}`); - frag = findFragmentByPDT(fragments, fragPrevious.endProgramDateTime, config.maxFragLookUpTolerance); - } else { - // Uses buffer and sequence number to calculate switch segment (required if using EXT-X-DISCONTINUITY-SEQUENCE) - const targetSN = fragPrevious.sn + 1; - if (targetSN >= levelDetails.startSN && targetSN <= levelDetails.endSN) { - const fragNext = fragments[targetSN - levelDetails.startSN]; - if (fragPrevious.cc === fragNext.cc) { - frag = fragNext; - logger.log(`live playlist, switching playlist, load frag with next SN: ${frag.sn}`); - } - } - // next frag SN not available (or not with same continuity counter) - // look for a frag sharing the same CC - if (!frag) { - frag = BinarySearch.search(fragments, function (frag) { - return fragPrevious.cc - frag.cc; - }); - if (frag) { - logger.log(`live playlist, switching playlist, load frag with same CC: ${frag.sn}`); - } - } - } - } - if (!frag) { - /* we have no idea about which fragment should be loaded. - so let's load mid fragment. it will help computing playlist sliding and find the right one - */ - frag = fragments[Math.min(fragLen - 1, Math.round(fragLen / 2))]; - logger.log(`live playlist, switching playlist, unknown, load middle frag : ${frag.sn}`); - } - } - - return frag; - } - - _findFragment (start, fragPrevious, fragLen, fragments, bufferEnd, end, levelDetails) { - const config = this.hls.config; - let frag; - - if (bufferEnd < end) { - const lookupTolerance = (bufferEnd > end - config.maxFragLookUpTolerance) ? 0 : config.maxFragLookUpTolerance; - // Remove the tolerance if it would put the bufferEnd past the actual end of stream - // Uses buffer and sequence number to calculate switch segment (required if using EXT-X-DISCONTINUITY-SEQUENCE) - frag = findFragmentByPTS(fragPrevious, fragments, bufferEnd, lookupTolerance); - } else { - // reach end of playlist - frag = fragments[fragLen - 1]; - } - if (frag) { - const curSNIdx = frag.sn - levelDetails.startSN; - const sameLevel = fragPrevious && frag.level === fragPrevious.level; - const prevFrag = fragments[curSNIdx - 1]; - const nextFrag = fragments[curSNIdx + 1]; - // logger.log('find SN matching with pos:' + bufferEnd + ':' + frag.sn); - if (fragPrevious && frag.sn === fragPrevious.sn) { - if (sameLevel && !frag.backtracked) { - if (frag.sn < levelDetails.endSN) { - let deltaPTS = fragPrevious.deltaPTS; - // if there is a significant delta between audio and video, larger than max allowed hole, - // and if previous remuxed fragment did not start with a keyframe. (fragPrevious.dropped) - // let's try to load previous fragment again to get last keyframe - // then we will reload again current fragment (that way we should be able to fill the buffer hole ...) - if (deltaPTS && deltaPTS > config.maxBufferHole && fragPrevious.dropped && curSNIdx) { - frag = prevFrag; - logger.warn('SN just loaded, with large PTS gap between audio and video, maybe frag is not starting with a keyframe ? load previous one to try to overcome this'); - } else { - frag = nextFrag; - logger.log(`SN just loaded, load next one: ${frag.sn}`, frag); - } - } else { - frag = null; - } - } else if (frag.backtracked) { - // Only backtrack a max of 1 consecutive fragment to prevent sliding back too far when little or no frags start with keyframes - if (nextFrag && nextFrag.backtracked) { - logger.warn(`Already backtracked from fragment ${nextFrag.sn}, will not backtrack to fragment ${frag.sn}. Loading fragment ${nextFrag.sn}`); - frag = nextFrag; - } else { - // If a fragment has dropped frames and it's in a same level/sequence, load the previous fragment to try and find the keyframe - // Reset the dropped count now since it won't be reset until we parse the fragment again, which prevents infinite backtracking on the same segment - logger.warn('Loaded fragment with dropped frames, backtracking 1 segment to find a keyframe'); - frag.dropped = 0; - if (prevFrag) { - frag = prevFrag; - frag.backtracked = true; - } else if (curSNIdx) { - // can't backtrack on very first fragment - frag = null; - } - } - } - } - } - return frag; - } - - _loadKey (frag) { - this.state = State.KEY_LOADING; - this.hls.trigger(Event.KEY_LOADING, { frag }); - } - - _loadFragment (frag) { - // Check if fragment is not loaded - let fragState = this.fragmentTracker.getState(frag); - - this.fragCurrent = frag; - if (frag.sn !== 'initSegment') { - this.startFragRequested = true; - } - // Don't update nextLoadPosition for fragments which are not buffered - if (Number.isFinite(frag.sn) && !frag.bitrateTest) { - this.nextLoadPosition = frag.start + frag.duration; - } - - // Allow backtracked fragments to load - if (frag.backtracked || fragState === FragmentState.NOT_LOADED || fragState === FragmentState.PARTIAL) { - frag.autoLevel = this.hls.autoLevelEnabled; - frag.bitrateTest = this.bitrateTest; - - this.hls.trigger(Event.FRAG_LOADING, { frag }); - // lazy demuxer init, as this could take some time ... do it during frag loading - if (!this.demuxer) { - this.demuxer = new Demuxer(this.hls, 'main'); - } - - this.state = State.FRAG_LOADING; - } else if (fragState === FragmentState.APPENDING) { - // Lower the buffer size and try again - if (this._reduceMaxBufferLength(frag.duration)) { - this.fragmentTracker.removeFragment(frag); - } - } - } - - set state (nextState) { - if (this.state !== nextState) { - const previousState = this.state; - this._state = nextState; - logger.log(`main stream:${previousState}->${nextState}`); - this.hls.trigger(Event.STREAM_STATE_TRANSITION, { previousState, nextState }); - } - } - - get state () { - return this._state; - } - - getBufferedFrag (position) { - return this.fragmentTracker.getBufferedFrag(position, PlaylistLevelType.MAIN); - } - - get currentLevel () { - let media = this.media; - if (media) { - const frag = this.getBufferedFrag(media.currentTime); - if (frag) { - return frag.level; - } - } - return -1; - } - - get nextBufferedFrag () { - let media = this.media; - if (media) { - // first get end range of current fragment - return this.followingBufferedFrag(this.getBufferedFrag(media.currentTime)); - } else { - return null; - } - } - - followingBufferedFrag (frag) { - if (frag) { - // try to get range of next fragment (500ms after this range) - return this.getBufferedFrag(frag.endPTS + 0.5); - } - return null; - } - - get nextLevel () { - const frag = this.nextBufferedFrag; - if (frag) { - return frag.level; - } else { - return -1; - } - } - - _checkFragmentChanged () { - let fragPlayingCurrent, currentTime, video = this.media; - if (video && video.readyState && video.seeking === false) { - currentTime = video.currentTime; - /* if video element is in seeked state, currentTime can only increase. - (assuming that playback rate is positive ...) - As sometimes currentTime jumps back to zero after a - media decode error, check this, to avoid seeking back to - wrong position after a media decode error - */ - if (currentTime > this.lastCurrentTime) { - this.lastCurrentTime = currentTime; - } - - if (BufferHelper.isBuffered(video, currentTime)) { - fragPlayingCurrent = this.getBufferedFrag(currentTime); - } else if (BufferHelper.isBuffered(video, currentTime + 0.1)) { - /* ensure that FRAG_CHANGED event is triggered at startup, - when first video frame is displayed and playback is paused. - add a tolerance of 100ms, in case current position is not buffered, - check if current pos+100ms is buffered and use that buffer range - for FRAG_CHANGED event reporting */ - fragPlayingCurrent = this.getBufferedFrag(currentTime + 0.1); - } - if (fragPlayingCurrent) { - let fragPlaying = fragPlayingCurrent; - if (fragPlaying !== this.fragPlaying) { - this.hls.trigger(Event.FRAG_CHANGED, { frag: fragPlaying }); - const fragPlayingLevel = fragPlaying.level; - if (!this.fragPlaying || this.fragPlaying.level !== fragPlayingLevel) { - this.hls.trigger(Event.LEVEL_SWITCHED, { level: fragPlayingLevel }); - } - - this.fragPlaying = fragPlaying; - } - } - } - } - - /* - on immediate level switch : - - pause playback if playing - - cancel any pending load request - - and trigger a buffer flush - */ - immediateLevelSwitch () { - logger.log('immediateLevelSwitch'); - if (!this.immediateSwitch) { - this.immediateSwitch = true; - let media = this.media, previouslyPaused; - if (media) { - previouslyPaused = media.paused; - media.pause(); - } else { - // don't restart playback after instant level switch in case media not attached - previouslyPaused = true; - } - this.previouslyPaused = previouslyPaused; - } - let fragCurrent = this.fragCurrent; - if (fragCurrent && fragCurrent.loader) { - fragCurrent.loader.abort(); - } - - this.fragCurrent = null; - // flush everything - this.flushMainBuffer(0, Number.POSITIVE_INFINITY); - } - - /** - * on immediate level switch end, after new fragment has been buffered: - * - nudge video decoder by slightly adjusting video currentTime (if currentTime buffered) - * - resume the playback if needed - */ - immediateLevelSwitchEnd () { - const media = this.media; - if (media && media.buffered.length) { - this.immediateSwitch = false; - if (BufferHelper.isBuffered(media, media.currentTime)) { - // only nudge if currentTime is buffered - media.currentTime -= 0.0001; - } - if (!this.previouslyPaused) { - media.play(); - } - } - } - - /** - * try to switch ASAP without breaking video playback: - * in order to ensure smooth but quick level switching, - * we need to find the next flushable buffer range - * we should take into account new segment fetch time - */ - nextLevelSwitch () { - const media = this.media; - // ensure that media is defined and that metadata are available (to retrieve currentTime) - if (media && media.readyState) { - let fetchdelay, fragPlayingCurrent, nextBufferedFrag; - fragPlayingCurrent = this.getBufferedFrag(media.currentTime); - if (fragPlayingCurrent && fragPlayingCurrent.startPTS > 1) { - // flush buffer preceding current fragment (flush until current fragment start offset) - // minus 1s to avoid video freezing, that could happen if we flush keyframe of current video ... - this.flushMainBuffer(0, fragPlayingCurrent.startPTS - 1); - } - if (!media.paused) { - // add a safety delay of 1s - let nextLevelId = this.hls.nextLoadLevel, nextLevel = this.levels[nextLevelId], fragLastKbps = this.fragLastKbps; - if (fragLastKbps && this.fragCurrent) { - fetchdelay = this.fragCurrent.duration * nextLevel.bitrate / (1000 * fragLastKbps) + 1; - } else { - fetchdelay = 0; - } - } else { - fetchdelay = 0; - } - // logger.log('fetchdelay:'+fetchdelay); - // find buffer range that will be reached once new fragment will be fetched - nextBufferedFrag = this.getBufferedFrag(media.currentTime + fetchdelay); - if (nextBufferedFrag) { - // we can flush buffer range following this one without stalling playback - nextBufferedFrag = this.followingBufferedFrag(nextBufferedFrag); - if (nextBufferedFrag) { - // if we are here, we can also cancel any loading/demuxing in progress, as they are useless - let fragCurrent = this.fragCurrent; - if (fragCurrent && fragCurrent.loader) { - fragCurrent.loader.abort(); - } - - this.fragCurrent = null; - // start flush position is the start PTS of next buffered frag. - // we use frag.naxStartPTS which is max(audio startPTS, video startPTS). - // in case there is a small PTS Delta between audio and video, using maxStartPTS avoids flushing last samples from current fragment - this.flushMainBuffer(nextBufferedFrag.maxStartPTS, Number.POSITIVE_INFINITY); - } - } - } - } - - flushMainBuffer (startOffset, endOffset) { - this.state = State.BUFFER_FLUSHING; - let flushScope = { startOffset: startOffset, endOffset: endOffset }; - // if alternate audio tracks are used, only flush video, otherwise flush everything - if (this.altAudio) { - flushScope.type = 'video'; - } - - this.hls.trigger(Event.BUFFER_FLUSHING, flushScope); - } - - onMediaAttached (data) { - let media = this.media = this.mediaBuffer = data.media; - this.onvseeking = this.onMediaSeeking.bind(this); - this.onvseeked = this.onMediaSeeked.bind(this); - this.onvended = this.onMediaEnded.bind(this); - media.addEventListener('seeking', this.onvseeking); - media.addEventListener('seeked', this.onvseeked); - media.addEventListener('ended', this.onvended); - let config = this.config; - if (this.levels && config.autoStartLoad) { - this.hls.startLoad(config.startPosition); - } - - this.gapController = new GapController(config, media, this.fragmentTracker, this.hls); - } - - onMediaDetaching () { - let media = this.media; - if (media && media.ended) { - logger.log('MSE detaching and video ended, reset startPosition'); - this.startPosition = this.lastCurrentTime = 0; - } - - // reset fragment backtracked flag - let levels = this.levels; - if (levels) { - levels.forEach(level => { - if (level.details) { - level.details.fragments.forEach(fragment => { - fragment.backtracked = undefined; - }); - } - }); - } - // remove video listeners - if (media) { - media.removeEventListener('seeking', this.onvseeking); - media.removeEventListener('seeked', this.onvseeked); - media.removeEventListener('ended', this.onvended); - this.onvseeking = this.onvseeked = this.onvended = null; - } - this.media = this.mediaBuffer = null; - this.loadedmetadata = false; - this.stopLoad(); - } - - onMediaSeeked () { - const media = this.media, currentTime = media ? media.currentTime : undefined; - if (Number.isFinite(currentTime)) { - logger.log(`media seeked to ${currentTime.toFixed(3)}`); - } - - // tick to speed up FRAGMENT_PLAYING triggering - this.tick(); - } - - onManifestLoading () { - // reset buffer on manifest loading - logger.log('trigger BUFFER_RESET'); - this.hls.trigger(Event.BUFFER_RESET); - this.fragmentTracker.removeAllFragments(); - this.stalled = false; - this.startPosition = this.lastCurrentTime = 0; - } - - onManifestParsed (data) { - let aac = false, heaac = false, codec; - data.levels.forEach(level => { - // detect if we have different kind of audio codecs used amongst playlists - codec = level.audioCodec; - if (codec) { - if (codec.indexOf('mp4a.40.2') !== -1) { - aac = true; - } - - if (codec.indexOf('mp4a.40.5') !== -1) { - heaac = true; - } - } - }); - this.audioCodecSwitch = (aac && heaac); - if (this.audioCodecSwitch) { - logger.log('both AAC/HE-AAC audio found in levels; declaring level codec as HE-AAC'); - } - - this.levels = data.levels; - this.startFragRequested = false; - let config = this.config; - if (config.autoStartLoad || this.forceStartLoad) { - this.hls.startLoad(config.startPosition); - } - } - - onLevelLoaded (data) { + onLevelLoaded(data) { const newDetails = data.details; const newLevelId = data.level; - const lastLevel = this.levels[this.levelLastLoaded]; - const curLevel = this.levels[newLevelId]; + const levelDetails = data.details; const duration = newDetails.totalduration; let sliding = 0; - logger.log(`level ${newLevelId} loaded [${newDetails.startSN},${newDetails.endSN}],duration:${duration}`); + console.log(`level ${newLevelId} loaded [${newDetails.startSN},${newDetails.endSN}],duration:${duration}`); if (newDetails.live) { - let curDetails = curLevel.details; - if (curDetails && newDetails.fragments.length > 0) { - // we already have details for that level, merge them - LevelHelper.mergeDetails(curDetails, newDetails); - sliding = newDetails.fragments[0].start; - this.liveSyncPosition = this.computeLivePosition(sliding, curDetails); - if (newDetails.PTSKnown && Number.isFinite(sliding)) { - logger.log(`live playlist sliding:${sliding.toFixed(3)}`); - } else { - logger.log('live playlist - outdated PTS, unknown sliding'); - alignStream(this.fragPrevious, lastLevel, newDetails); - } - } else { - logger.log('live playlist - first load, unknown sliding'); - newDetails.PTSKnown = false; - alignStream(this.fragPrevious, lastLevel, newDetails); - } + console.log("handling of this case is not implemented"); } else { newDetails.PTSKnown = false; } // override level info - curLevel.details = newDetails; this.levelLastLoaded = newLevelId; this.hls.trigger(Event.LEVEL_UPDATED, { details: newDetails, level: newLevelId }); if (this.startFragRequested === false) { - // compute start position if set to -1. use it straight away if value is defined + // compute start position if set to -1. use it straight away if value is defined if (this.startPosition === -1 || this.lastCurrentTime === -1) { // first, check if start time offset has been set in playlist, if yes, use this value let startTimeOffset = newDetails.startTimeOffset; if (Number.isFinite(startTimeOffset)) { if (startTimeOffset < 0) { - logger.log(`negative start time offset ${startTimeOffset}, count from end of last fragment`); + console.log(`negative start time offset ${startTimeOffset}, count from end of last fragment`); startTimeOffset = sliding + duration + startTimeOffset; } - logger.log(`start time offset found in playlist, adjust startPosition to ${startTimeOffset}`); + console.log(`start time offset found in playlist, adjust startPosition to ${startTimeOffset}`); this.startPosition = startTimeOffset; } else { // if live playlist, set start position to be fragment N-this.config.liveSyncDurationCount (usually 3) if (newDetails.live) { - this.startPosition = this.computeLivePosition(sliding, newDetails); - logger.log(`configure startPosition to ${this.startPosition}`); + console.log("handling of this case is not implemented"); } else { this.startPosition = 0; } @@ -804,534 +96,14 @@ class StreamController extends BaseStreamController { } this.nextLoadPosition = this.startPosition; } - // only switch batck to IDLE state if we were waiting for level to start downloading a new fragment - if (this.state === State.WAITING_LEVEL) { - this.state = State.IDLE; - } - // trigger handler right now - this.tick(); + this._fetchPayloadOrEos(levelDetails); } - onKeyLoaded () { - if (this.state === State.KEY_LOADING) { - this.state = State.IDLE; - this.tick(); - } - } + onFragLoaded(data) { + this.hls.loadSuccess(data.payload); + this._loadFragment(); - onFragLoaded (data) { - const { fragCurrent, hls, levels, media } = this; - const fragLoaded = data.frag; - if (this.state === State.FRAG_LOADING && - fragCurrent && - fragLoaded.type === 'main' && - fragLoaded.level === fragCurrent.level && - fragLoaded.sn === fragCurrent.sn) { - const stats = data.stats; - const currentLevel = levels[fragCurrent.level]; - const details = currentLevel.details; - // reset frag bitrate test in any case after frag loaded event - // if this frag was loaded to perform a bitrate test AND if hls.nextLoadLevel is greater than 0 - // then this means that we should be able to load a fragment at a higher quality level - this.bitrateTest = false; - this.stats = stats; - - logger.log(`Loaded ${fragCurrent.sn} of [${details.startSN} ,${details.endSN}],level ${fragCurrent.level}`); - if (fragLoaded.bitrateTest && hls.nextLoadLevel) { - // switch back to IDLE state ... we just loaded a fragment to determine adequate start bitrate and initialize autoswitch algo - this.state = State.IDLE; - this.startFragRequested = false; - stats.tparsed = stats.tbuffered = window.performance.now(); - hls.trigger(Event.FRAG_BUFFERED, { stats: stats, frag: fragCurrent, id: 'main' }); - this.tick(); - } else if (fragLoaded.sn === 'initSegment') { - this.state = State.IDLE; - stats.tparsed = stats.tbuffered = window.performance.now(); - details.initSegment.data = data.payload; - hls.trigger(Event.FRAG_BUFFERED, { stats: stats, frag: fragCurrent, id: 'main' }); - this.tick(); - } else { - logger.log(`Parsing ${fragCurrent.sn} of [${details.startSN} ,${details.endSN}],level ${fragCurrent.level}, cc ${fragCurrent.cc}`); - this.state = State.PARSING; - this.pendingBuffering = true; - this.appended = false; - - // Bitrate test frags are not usually buffered so the fragment tracker ignores them. If Hls.js decides to buffer - // it (and therefore ends up at this line), then the fragment tracker needs to be manually informed. - if (fragLoaded.bitrateTest) { - fragLoaded.bitrateTest = false; - this.fragmentTracker.onFragLoaded({ - frag: fragLoaded - }); - } - - // time Offset is accurate if level PTS is known, or if playlist is not sliding (not live) and if media is not seeking (this is to overcome potential timestamp drifts between playlists and fragments) - const accurateTimeOffset = !(media && media.seeking) && (details.PTSKnown || !details.live); - const initSegmentData = details.initSegment ? details.initSegment.data : []; - const audioCodec = this._getAudioCodec(currentLevel); - - // transmux the MPEG-TS data to ISO-BMFF segments - const demuxer = this.demuxer = this.demuxer || new Demuxer(this.hls, 'main'); - demuxer.push( - data.payload, - initSegmentData, - audioCodec, - currentLevel.videoCodec, - fragCurrent, - details.totalduration, - accurateTimeOffset - ); - } - } - this.fragLoadError = 0; - } - - onFragParsingInitSegment (data) { - const fragCurrent = this.fragCurrent; - const fragNew = data.frag; - - if (fragCurrent && - data.id === 'main' && - fragNew.sn === fragCurrent.sn && - fragNew.level === fragCurrent.level && - this.state === State.PARSING) { - let tracks = data.tracks, trackName, track; - - // if audio track is expected to come from audio stream controller, discard any coming from main - if (tracks.audio && this.altAudio) { - delete tracks.audio; - } - - // include levelCodec in audio and video tracks - track = tracks.audio; - if (track) { - let audioCodec = this.levels[this.level].audioCodec, - ua = navigator.userAgent.toLowerCase(); - if (audioCodec && this.audioCodecSwap) { - logger.log('swapping playlist audio codec'); - if (audioCodec.indexOf('mp4a.40.5') !== -1) { - audioCodec = 'mp4a.40.2'; - } else { - audioCodec = 'mp4a.40.5'; - } - } - // in case AAC and HE-AAC audio codecs are signalled in manifest - // force HE-AAC , as it seems that most browsers prefers that way, - // except for mono streams OR on FF - // these conditions might need to be reviewed ... - if (this.audioCodecSwitch) { - // don't force HE-AAC if mono stream - if (track.metadata.channelCount !== 1 && - // don't force HE-AAC if firefox - ua.indexOf('firefox') === -1) { - audioCodec = 'mp4a.40.5'; - } - } - // HE-AAC is broken on Android, always signal audio codec as AAC even if variant manifest states otherwise - if (ua.indexOf('android') !== -1 && track.container !== 'audio/mpeg') { // Exclude mpeg audio - audioCodec = 'mp4a.40.2'; - logger.log(`Android: force audio codec to ${audioCodec}`); - } - track.levelCodec = audioCodec; - track.id = data.id; - } - track = tracks.video; - if (track) { - track.levelCodec = this.levels[this.level].videoCodec; - track.id = data.id; - } - this.hls.trigger(Event.BUFFER_CODECS, tracks); - // loop through tracks that are going to be provided to bufferController - for (trackName in tracks) { - track = tracks[trackName]; - logger.log(`main track:${trackName},container:${track.container},codecs[level/parsed]=[${track.levelCodec}/${track.codec}]`); - let initSegment = track.initSegment; - if (initSegment) { - this.appended = true; - // arm pending Buffering flag before appending a segment - this.pendingBuffering = true; - this.hls.trigger(Event.BUFFER_APPENDING, { type: trackName, data: initSegment, parent: 'main', content: 'initSegment' }); - } - } - // trigger handler right now - this.tick(); - } - } - - onFragParsingData (data) { - const fragCurrent = this.fragCurrent; - const fragNew = data.frag; - if (fragCurrent && - data.id === 'main' && - fragNew.sn === fragCurrent.sn && - fragNew.level === fragCurrent.level && - !(data.type === 'audio' && this.altAudio) && // filter out main audio if audio track is loaded through audio stream controller - this.state === State.PARSING) { - let level = this.levels[this.level], - frag = fragCurrent; - if (!Number.isFinite(data.endPTS)) { - data.endPTS = data.startPTS + fragCurrent.duration; - data.endDTS = data.startDTS + fragCurrent.duration; - } - - if (data.hasAudio === true) { - frag.addElementaryStream(ElementaryStreamTypes.AUDIO); - } - - if (data.hasVideo === true) { - frag.addElementaryStream(ElementaryStreamTypes.VIDEO); - } - - logger.log(`Parsed ${data.type},PTS:[${data.startPTS.toFixed(3)},${data.endPTS.toFixed(3)}],DTS:[${data.startDTS.toFixed(3)}/${data.endDTS.toFixed(3)}],nb:${data.nb},dropped:${data.dropped || 0}`); - - // Detect gaps in a fragment and try to fix it by finding a keyframe in the previous fragment (see _findFragments) - if (data.type === 'video') { - frag.dropped = data.dropped; - if (frag.dropped) { - if (!frag.backtracked) { - const levelDetails = level.details; - if (levelDetails && frag.sn === levelDetails.startSN) { - logger.warn('missing video frame(s) on first frag, appending with gap', frag.sn); - } else { - logger.warn('missing video frame(s), backtracking fragment', frag.sn); - // Return back to the IDLE state without appending to buffer - // Causes findFragments to backtrack a segment and find the keyframe - // Audio fragments arriving before video sets the nextLoadPosition, causing _findFragments to skip the backtracked fragment - this.fragmentTracker.removeFragment(frag); - frag.backtracked = true; - this.nextLoadPosition = data.startPTS; - this.state = State.IDLE; - this.fragPrevious = frag; - this.tick(); - return; - } - } else { - logger.warn('Already backtracked on this fragment, appending with the gap', frag.sn); - } - } else { - // Only reset the backtracked flag if we've loaded the frag without any dropped frames - frag.backtracked = false; - } - } - - let drift = LevelHelper.updateFragPTSDTS(level.details, frag, data.startPTS, data.endPTS, data.startDTS, data.endDTS), - hls = this.hls; - hls.trigger(Event.LEVEL_PTS_UPDATED, { details: level.details, level: this.level, drift: drift, type: data.type, start: data.startPTS, end: data.endPTS }); - // has remuxer dropped video frames located before first keyframe ? - [data.data1, data.data2].forEach(buffer => { - // only append in PARSING state (rationale is that an appending error could happen synchronously on first segment appending) - // in that case it is useless to append following segments - if (buffer && buffer.length && this.state === State.PARSING) { - this.appended = true; - // arm pending Buffering flag before appending a segment - this.pendingBuffering = true; - hls.trigger(Event.BUFFER_APPENDING, { type: data.type, data: buffer, parent: 'main', content: 'data' }); - } - }); - // trigger handler right now - this.tick(); - } - } - - onFragParsed (data) { - const fragCurrent = this.fragCurrent; - const fragNew = data.frag; - if (fragCurrent && - data.id === 'main' && - fragNew.sn === fragCurrent.sn && - fragNew.level === fragCurrent.level && - this.state === State.PARSING) { - this.stats.tparsed = window.performance.now(); - this.state = State.PARSED; - this._checkAppendedParsed(); - } - } - - onAudioTrackSwitching (data) { - // if any URL found on new audio track, it is an alternate audio track - let altAudio = !!data.url, - trackId = data.id; - // if we switch on main audio, ensure that main fragment scheduling is synced with media.buffered - // don't do anything if we switch to alt audio: audio stream controller is handling it. - // we will just have to change buffer scheduling on audioTrackSwitched - if (!altAudio) { - if (this.mediaBuffer !== this.media) { - logger.log('switching on main audio, use media.buffered to schedule main fragment loading'); - this.mediaBuffer = this.media; - let fragCurrent = this.fragCurrent; - // we need to refill audio buffer from main: cancel any frag loading to speed up audio switch - if (fragCurrent.loader) { - logger.log('switching to main audio track, cancel main fragment load'); - fragCurrent.loader.abort(); - } - this.fragCurrent = null; - this.fragPrevious = null; - // destroy demuxer to force init segment generation (following audio switch) - if (this.demuxer) { - this.demuxer.destroy(); - this.demuxer = null; - } - // switch to IDLE state to load new fragment - this.state = State.IDLE; - } - let hls = this.hls; - // switching to main audio, flush all audio and trigger track switched - hls.trigger(Event.BUFFER_FLUSHING, { startOffset: 0, endOffset: Number.POSITIVE_INFINITY, type: 'audio' }); - hls.trigger(Event.AUDIO_TRACK_SWITCHED, { id: trackId }); - this.altAudio = false; - } - } - - onAudioTrackSwitched (data) { - let trackId = data.id, - altAudio = !!this.hls.audioTracks[trackId].url; - if (altAudio) { - let videoBuffer = this.videoBuffer; - // if we switched on alternate audio, ensure that main fragment scheduling is synced with video sourcebuffer buffered - if (videoBuffer && this.mediaBuffer !== videoBuffer) { - logger.log('switching on alternate audio, use video.buffered to schedule main fragment loading'); - this.mediaBuffer = videoBuffer; - } - } - this.altAudio = altAudio; - this.tick(); - } - - onBufferCreated (data) { - let tracks = data.tracks, mediaTrack, name, alternate = false; - for (let type in tracks) { - let track = tracks[type]; - if (track.id === 'main') { - name = type; - mediaTrack = track; - // keep video source buffer reference - if (type === 'video') { - this.videoBuffer = tracks[type].buffer; - } - } else { - alternate = true; - } - } - if (alternate && mediaTrack) { - logger.log(`alternate track found, use ${name}.buffered to schedule main fragment loading`); - this.mediaBuffer = mediaTrack.buffer; - } else { - this.mediaBuffer = this.media; - } - } - - onBufferAppended (data) { - if (data.parent === 'main') { - const state = this.state; - if (state === State.PARSING || state === State.PARSED) { - // check if all buffers have been appended - this.pendingBuffering = (data.pending > 0); - this._checkAppendedParsed(); - } - } - } - - _checkAppendedParsed () { - // trigger handler right now - if (this.state === State.PARSED && (!this.appended || !this.pendingBuffering)) { - const frag = this.fragCurrent; - if (frag) { - const media = this.mediaBuffer ? this.mediaBuffer : this.media; - logger.log(`main buffered : ${TimeRanges.toString(media.buffered)}`); - this.fragPrevious = frag; - const stats = this.stats; - stats.tbuffered = window.performance.now(); - // we should get rid of this.fragLastKbps - this.fragLastKbps = Math.round(8 * stats.total / (stats.tbuffered - stats.tfirst)); - this.hls.trigger(Event.FRAG_BUFFERED, { stats: stats, frag: frag, id: 'main' }); - this.state = State.IDLE; - } - this.tick(); - } - } - - onError (data) { - let frag = data.frag || this.fragCurrent; - // don't handle frag error not related to main fragment - if (frag && frag.type !== 'main') { - return; - } - - // 0.5 : tolerance needed as some browsers stalls playback before reaching buffered end - let mediaBuffered = !!this.media && BufferHelper.isBuffered(this.media, this.media.currentTime) && BufferHelper.isBuffered(this.media, this.media.currentTime + 0.5); - - switch (data.details) { - case ErrorDetails.FRAG_LOAD_ERROR: - case ErrorDetails.FRAG_LOAD_TIMEOUT: - case ErrorDetails.KEY_LOAD_ERROR: - case ErrorDetails.KEY_LOAD_TIMEOUT: - if (!data.fatal) { - // keep retrying until the limit will be reached - if ((this.fragLoadError + 1) <= this.config.fragLoadingMaxRetry) { - // exponential backoff capped to config.fragLoadingMaxRetryTimeout - let delay = Math.min(Math.pow(2, this.fragLoadError) * this.config.fragLoadingRetryDelay, this.config.fragLoadingMaxRetryTimeout); - logger.warn(`mediaController: frag loading failed, retry in ${delay} ms`); - this.retryDate = window.performance.now() + delay; - // retry loading state - // if loadedmetadata is not set, it means that we are emergency switch down on first frag - // in that case, reset startFragRequested flag - if (!this.loadedmetadata) { - this.startFragRequested = false; - this.nextLoadPosition = this.startPosition; - } - this.fragLoadError++; - this.state = State.FRAG_LOADING_WAITING_RETRY; - } else { - logger.error(`mediaController: ${data.details} reaches max retry, redispatch as fatal ...`); - // switch error to fatal - data.fatal = true; - this.state = State.ERROR; - } - } - break; - case ErrorDetails.LEVEL_LOAD_ERROR: - case ErrorDetails.LEVEL_LOAD_TIMEOUT: - if (this.state !== State.ERROR) { - if (data.fatal) { - // if fatal error, stop processing - this.state = State.ERROR; - logger.warn(`streamController: ${data.details},switch to ${this.state} state ...`); - } else { - // in case of non fatal error while loading level, if level controller is not retrying to load level , switch back to IDLE - if (!data.levelRetry && this.state === State.WAITING_LEVEL) { - this.state = State.IDLE; - } - } - } - break; - case ErrorDetails.BUFFER_FULL_ERROR: - // if in appending state - if (data.parent === 'main' && (this.state === State.PARSING || this.state === State.PARSED)) { - // reduce max buf len if current position is buffered - if (mediaBuffered) { - this._reduceMaxBufferLength(this.config.maxBufferLength); - this.state = State.IDLE; - } else { - // current position is not buffered, but browser is still complaining about buffer full error - // this happens on IE/Edge, refer to https://github.com/video-dev/hls.js/pull/708 - // in that case flush the whole buffer to recover - logger.warn('buffer full error also media.currentTime is not buffered, flush everything'); - this.fragCurrent = null; - // flush everything - this.flushMainBuffer(0, Number.POSITIVE_INFINITY); - } - } - break; - default: - break; - } - } - - _reduceMaxBufferLength (minLength) { - let config = this.config; - if (config.maxMaxBufferLength >= minLength) { - // reduce max buffer length as it might be too high. we do this to avoid loop flushing ... - config.maxMaxBufferLength /= 2; - logger.warn(`main:reduce max buffer length to ${config.maxMaxBufferLength}s`); - return true; - } - return false; - } - - /** - * Checks the health of the buffer and attempts to resolve playback stalls. - * @private - */ - _checkBuffer () { - const { media } = this; - if (!media || media.readyState === 0) { - // Exit early if we don't have media or if the media hasn't bufferd anything yet (readyState 0) - return; - } - - const mediaBuffer = this.mediaBuffer ? this.mediaBuffer : media; - const buffered = mediaBuffer.buffered; - - if (!this.loadedmetadata && buffered.length) { - this.loadedmetadata = true; - this._seekToStartPos(); - } else if (this.immediateSwitch) { - this.immediateLevelSwitchEnd(); - } else { - this.gapController.poll(this.lastCurrentTime, buffered); - } - } - - onFragLoadEmergencyAborted () { - this.state = State.IDLE; - // if loadedmetadata is not set, it means that we are emergency switch down on first frag - // in that case, reset startFragRequested flag - if (!this.loadedmetadata) { - this.startFragRequested = false; - this.nextLoadPosition = this.startPosition; - } - this.tick(); - } - - onBufferFlushed () { - /* after successful buffer flushing, filter flushed fragments from bufferedFrags - use mediaBuffered instead of media (so that we will check against video.buffered ranges in case of alt audio track) - */ - const media = this.mediaBuffer ? this.mediaBuffer : this.media; - if (media) { - // filter fragments potentially evicted from buffer. this is to avoid memleak on live streams - this.fragmentTracker.detectEvictedFragments(ElementaryStreamTypes.VIDEO, media.buffered); - } - // move to IDLE once flush complete. this should trigger new fragment loading - this.state = State.IDLE; - // reset reference to frag - this.fragPrevious = null; - } - - swapAudioCodec () { - this.audioCodecSwap = !this.audioCodecSwap; - } - /** - * Seeks to the set startPosition if not equal to the mediaElement's current time. - * @private - */ - _seekToStartPos () { - const { media } = this; - const currentTime = media.currentTime; - // only adjust currentTime if different from startPosition or if startPosition not buffered - // at that stage, there should be only one buffered range, as we reach that code after first fragment has been buffered - const startPosition = media.seeking ? currentTime : this.startPosition; - // if currentTime not matching with expected startPosition or startPosition not buffered but close to first buffered - if (currentTime !== startPosition) { - // if startPosition not buffered, let's seek to buffered.start(0) - logger.log(`target start position not buffered, seek to buffered.start(0) ${startPosition} from current time ${currentTime} `); - media.currentTime = startPosition; - } - } - - _getAudioCodec (currentLevel) { - let audioCodec = this.config.defaultAudioCodec || currentLevel.audioCodec; - if (this.audioCodecSwap) { - logger.log('swapping playlist audio codec'); - if (audioCodec) { - if (audioCodec.indexOf('mp4a.40.5') !== -1) { - audioCodec = 'mp4a.40.2'; - } else { - audioCodec = 'mp4a.40.5'; - } - } - } - - return audioCodec; - } - - get liveSyncPosition () { - return this._liveSyncPosition; - } - - set liveSyncPosition (value) { - this._liveSyncPosition = value; } } export default StreamController; diff --git a/cmd/mjpeg-player/hlsjs/hls.js b/cmd/mjpeg-player/hlsjs/hls.js index 88107af2..a344d330 100644 --- a/cmd/mjpeg-player/hlsjs/hls.js +++ b/cmd/mjpeg-player/hlsjs/hls.js @@ -1,674 +1,50 @@ -import * as URLToolkit from 'url-toolkit'; +/* +AUTHOR + Trek Hopton -import { - ErrorTypes, - ErrorDetails -} from './errors'; +LICENSE + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) -import PlaylistLoader from './loader/playlist-loader'; -import FragmentLoader from './loader/fragment-loader'; -import KeyLoader from './loader/key-loader'; + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. -import { FragmentTracker } from './controller/fragment-tracker'; -import StreamController from './controller/stream-controller'; -import LevelController from './controller/level-controller'; -import ID3TrackController from './controller/id3-track-controller'; + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. -import { isSupported } from './is-supported'; -import { logger, enableLogs } from './utils/logger'; -import { hlsDefaultConfig, HlsConfig } from './config'; + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. -import HlsEvents from './events'; + For hls.js Copyright notice and license, see LICENSE file. +*/ -import { Observer } from './observer'; +import URLToolkit from '../url-toolkit/url-toolkit.js'; +import HlsEvents from './events.js'; +import PlaylistLoader from './loader/playlist-loader.js'; +import FragmentLoader from './loader/fragment-loader.js'; +import StreamController from './controller/stream-controller.js'; +import { hlsDefaultConfig } from './config.js'; +import { Observer } from './observer.js'; -/** - * @module Hls - * @class - * @constructor - */ -export default class Hls extends Observer { - public static defaultConfig?: HlsConfig; - public config: HlsConfig; - - private _autoLevelCapping: number; - private abrController: any; - private capLevelController: any; - private levelController: any; - private streamController: any; - private networkControllers: any[]; - private audioTrackController: any; - private subtitleTrackController: any; - private emeController: any; - private coreComponents: any[]; - private media: HTMLMediaElement | null = null; - private url: string | null = null; - - /** - * @type {string} - */ - static get version (): string { - return __VERSION__; - } - - /** - * @type {boolean} - */ - static isSupported (): boolean { - return isSupported(); - } - - /** - * @type {HlsEvents} - */ - static get Events () { - return HlsEvents; - } - - /** - * @type {HlsErrorTypes} - */ - static get ErrorTypes () { - return ErrorTypes; - } - - /** - * @type {HlsErrorDetails} - */ - static get ErrorDetails () { - return ErrorDetails; - } - - /** - * @type {HlsConfig} - */ - static get DefaultConfig (): HlsConfig { - if (!Hls.defaultConfig) { - return hlsDefaultConfig; - } - - return Hls.defaultConfig; - } - - /** - * @type {HlsConfig} - */ - static set DefaultConfig (defaultConfig: HlsConfig) { - Hls.defaultConfig = defaultConfig; - } - - /** - * Creates an instance of an HLS client that can attach to exactly one `HTMLMediaElement`. - * - * @constructs Hls - * @param {HlsConfig} config - */ - constructor (userConfig: Partial = {}) { +class Hls extends Observer { + constructor() { super(); + this.pLoader = new PlaylistLoader(this); + this.streamController = new StreamController(this); + this.fragmentLoader = new FragmentLoader(this); - const defaultConfig = Hls.DefaultConfig; - - if ((userConfig.liveSyncDurationCount || userConfig.liveMaxLatencyDurationCount) && (userConfig.liveSyncDuration || userConfig.liveMaxLatencyDuration)) { - throw new Error('Illegal hls.js config: don\'t mix up liveSyncDurationCount/liveMaxLatencyDurationCount and liveSyncDuration/liveMaxLatencyDuration'); - } - - // Shallow clone - this.config = { - ...defaultConfig, - ...userConfig - }; - - const { config } = this; - - if (config.liveMaxLatencyDurationCount !== void 0 && config.liveMaxLatencyDurationCount <= config.liveSyncDurationCount) { - throw new Error('Illegal hls.js config: "liveMaxLatencyDurationCount" must be gt "liveSyncDurationCount"'); - } - - if (config.liveMaxLatencyDuration !== void 0 && (config.liveSyncDuration === void 0 || config.liveMaxLatencyDuration <= config.liveSyncDuration)) { - throw new Error('Illegal hls.js config: "liveMaxLatencyDuration" must be gt "liveSyncDuration"'); - } - - enableLogs(config.debug); - - this._autoLevelCapping = -1; - - // core controllers and network loaders - - /** - * @member {AbrController} abrController - */ - const abrController = this.abrController = new config.abrController(this); // eslint-disable-line new-cap - const bufferController = new config.bufferController(this); // eslint-disable-line new-cap - const capLevelController = this.capLevelController = new config.capLevelController(this); // eslint-disable-line new-cap - const fpsController = new config.fpsController(this); // eslint-disable-line new-cap - const playListLoader = new PlaylistLoader(this); - const fragmentLoader = new FragmentLoader(this); - const keyLoader = new KeyLoader(this); - const id3TrackController = new ID3TrackController(this); - - // network controllers - - /** - * @member {LevelController} levelController - */ - const levelController = this.levelController = new LevelController(this); - - // FIXME: FragmentTracker must be defined before StreamController because the order of event handling is important - const fragmentTracker = new FragmentTracker(this); - - /** - * @member {StreamController} streamController - */ - const streamController = this.streamController = new StreamController(this, fragmentTracker); - - let networkControllers = [levelController, streamController]; - - // optional audio stream controller - /** - * @var {ICoreComponent | Controller} - */ - let Controller = config.audioStreamController; - if (Controller) { - networkControllers.push(new Controller(this, fragmentTracker)); - } - - /** - * @member {INetworkController[]} networkControllers - */ - this.networkControllers = networkControllers; - - /** - * @var {ICoreComponent[]} - */ - const coreComponents = [ - playListLoader, - fragmentLoader, - keyLoader, - abrController, - bufferController, - capLevelController, - fpsController, - id3TrackController, - fragmentTracker - ]; - - // optional audio track and subtitle controller - Controller = config.audioTrackController; - if (Controller) { - const audioTrackController = new Controller(this); - - /** - * @member {AudioTrackController} audioTrackController - */ - this.audioTrackController = audioTrackController; - coreComponents.push(audioTrackController); - } - - Controller = config.subtitleTrackController; - if (Controller) { - const subtitleTrackController = new Controller(this); - - /** - * @member {SubtitleTrackController} subtitleTrackController - */ - this.subtitleTrackController = subtitleTrackController; - networkControllers.push(subtitleTrackController); - } - - Controller = config.emeController; - if (Controller) { - const emeController = new Controller(this); - - /** - * @member {EMEController} emeController - */ - this.emeController = emeController; - coreComponents.push(emeController); - } - - // optional subtitle controllers - Controller = config.subtitleStreamController; - if (Controller) { - networkControllers.push(new Controller(this, fragmentTracker)); - } - Controller = config.timelineController; - if (Controller) { - coreComponents.push(new Controller(this)); - } - - /** - * @member {ICoreComponent[]} - */ - this.coreComponents = coreComponents; + this.config = hlsDefaultConfig; } - /** - * Dispose of the instance - */ - destroy () { - logger.log('destroy'); - this.trigger(HlsEvents.DESTROYING); - this.detachMedia(); - this.coreComponents.concat(this.networkControllers).forEach(component => { - component.destroy(); - }); - this.url = null; - this.removeAllListeners(); - this._autoLevelCapping = -1; - } - - /** - * Attach a media element - * @param {HTMLMediaElement} media - */ - attachMedia (media: HTMLMediaElement) { - logger.log('attachMedia'); - this.media = media; - this.trigger(HlsEvents.MEDIA_ATTACHING, { media: media }); - } - - /** - * Detach from the media - */ - detachMedia () { - logger.log('detachMedia'); - this.trigger(HlsEvents.MEDIA_DETACHING); - this.media = null; - } - - /** - * Set the source URL. Can be relative or absolute. - * @param {string} url - */ - loadSource (url: string) { + // url is the source URL. Can be relative or absolute. + loadSource(url, callback) { + this.loadSuccess = callback; url = URLToolkit.buildAbsoluteURL(window.location.href, url, { alwaysNormalize: true }); - logger.log(`loadSource:${url}`); - this.url = url; - // when attaching to a source URL, trigger a playlist load this.trigger(HlsEvents.MANIFEST_LOADING, { url: url }); } - - /** - * Start loading data from the stream source. - * Depending on default config, client starts loading automatically when a source is set. - * - * @param {number} startPosition Set the start position to stream from - * @default -1 None (from earliest point) - */ - startLoad (startPosition: number = -1) { - logger.log(`startLoad(${startPosition})`); - this.networkControllers.forEach(controller => { - controller.startLoad(startPosition); - }); - } - - /** - * Stop loading of any stream data. - */ - stopLoad () { - logger.log('stopLoad'); - this.networkControllers.forEach(controller => { - controller.stopLoad(); - }); - } - - /** - * Swap through possible audio codecs in the stream (for example to switch from stereo to 5.1) - */ - swapAudioCodec () { - logger.log('swapAudioCodec'); - this.streamController.swapAudioCodec(); - } - - /** - * When the media-element fails, this allows to detach and then re-attach it - * as one call (convenience method). - * - * Automatic recovery of media-errors by this process is configurable. - */ - recoverMediaError () { - logger.log('recoverMediaError'); - let media = this.media; - this.detachMedia(); - if (media) { - this.attachMedia(media); - } - } - - /** - * @type {QualityLevel[]} - */ - // todo(typescript-levelController) - get levels (): any[] { - return this.levelController.levels; - } - - /** - * Index of quality level currently played - * @type {number} - */ - get currentLevel (): number { - return this.streamController.currentLevel; - } - - /** - * Set quality level index immediately . - * This will flush the current buffer to replace the quality asap. - * That means playback will interrupt at least shortly to re-buffer and re-sync eventually. - * @type {number} -1 for automatic level selection - */ - set currentLevel (newLevel: number) { - logger.log(`set currentLevel:${newLevel}`); - this.loadLevel = newLevel; - this.streamController.immediateLevelSwitch(); - } - - /** - * Index of next quality level loaded as scheduled by stream controller. - * @type {number} - */ - get nextLevel (): number { - return this.streamController.nextLevel; - } - - /** - * Set quality level index for next loaded data. - * This will switch the video quality asap, without interrupting playback. - * May abort current loading of data, and flush parts of buffer (outside currently played fragment region). - * @type {number} -1 for automatic level selection - */ - set nextLevel (newLevel: number) { - logger.log(`set nextLevel:${newLevel}`); - this.levelController.manualLevel = newLevel; - this.streamController.nextLevelSwitch(); - } - - /** - * Return the quality level of the currently or last (of none is loaded currently) segment - * @type {number} - */ - get loadLevel (): number { - return this.levelController.level; - } - - /** - * Set quality level index for next loaded data in a conservative way. - * This will switch the quality without flushing, but interrupt current loading. - * Thus the moment when the quality switch will appear in effect will only be after the already existing buffer. - * @type {number} newLevel -1 for automatic level selection - */ - set loadLevel (newLevel: number) { - logger.log(`set loadLevel:${newLevel}`); - this.levelController.manualLevel = newLevel; - } - - /** - * get next quality level loaded - * @type {number} - */ - get nextLoadLevel (): number { - return this.levelController.nextLoadLevel; - } - - /** - * Set quality level of next loaded segment in a fully "non-destructive" way. - * Same as `loadLevel` but will wait for next switch (until current loading is done). - * @type {number} level - */ - set nextLoadLevel (level: number) { - this.levelController.nextLoadLevel = level; - } - - /** - * Return "first level": like a default level, if not set, - * falls back to index of first level referenced in manifest - * @type {number} - */ - get firstLevel (): number { - return Math.max(this.levelController.firstLevel, this.minAutoLevel); - } - - /** - * Sets "first-level", see getter. - * @type {number} - */ - set firstLevel (newLevel: number) { - logger.log(`set firstLevel:${newLevel}`); - this.levelController.firstLevel = newLevel; - } - - /** - * Return start level (level of first fragment that will be played back) - * if not overrided by user, first level appearing in manifest will be used as start level - * if -1 : automatic start level selection, playback will start from level matching download bandwidth - * (determined from download of first segment) - * @type {number} - */ - get startLevel (): number { - return this.levelController.startLevel; - } - - /** - * set start level (level of first fragment that will be played back) - * if not overrided by user, first level appearing in manifest will be used as start level - * if -1 : automatic start level selection, playback will start from level matching download bandwidth - * (determined from download of first segment) - * @type {number} newLevel - */ - set startLevel (newLevel: number) { - logger.log(`set startLevel:${newLevel}`); - // if not in automatic start level detection, ensure startLevel is greater than minAutoLevel - if (newLevel !== -1) { - newLevel = Math.max(newLevel, this.minAutoLevel); - } - - this.levelController.startLevel = newLevel; - } - - /** - * set dynamically set capLevelToPlayerSize against (`CapLevelController`) - * - * @type {boolean} - */ - set capLevelToPlayerSize (shouldStartCapping: boolean) { - const newCapLevelToPlayerSize = !!shouldStartCapping; - - if (newCapLevelToPlayerSize !== this.config.capLevelToPlayerSize) { - if (newCapLevelToPlayerSize) { - this.capLevelController.startCapping(); // If capping occurs, nextLevelSwitch will happen based on size. - } else { - this.capLevelController.stopCapping(); - this.autoLevelCapping = -1; - this.streamController.nextLevelSwitch(); // Now we're uncapped, get the next level asap. - } - - this.config.capLevelToPlayerSize = newCapLevelToPlayerSize; - } - } - - /** - * Capping/max level value that should be used by automatic level selection algorithm (`ABRController`) - * @type {number} - */ - get autoLevelCapping (): number { - return this._autoLevelCapping; - } - - /** - * get bandwidth estimate - * @type {number} - */ - get bandwidthEstimate (): number { - const bwEstimator = this.abrController._bwEstimator; - return bwEstimator ? bwEstimator.getEstimate() : NaN; - } - - /** - * Capping/max level value that should be used by automatic level selection algorithm (`ABRController`) - * @type {number} - */ - set autoLevelCapping (newLevel: number) { - logger.log(`set autoLevelCapping:${newLevel}`); - this._autoLevelCapping = newLevel; - } - - /** - * True when automatic level selection enabled - * @type {boolean} - */ - get autoLevelEnabled (): boolean { - return (this.levelController.manualLevel === -1); - } - - /** - * Level set manually (if any) - * @type {number} - */ - get manualLevel (): number { - return this.levelController.manualLevel; - } - - /** - * min level selectable in auto mode according to config.minAutoBitrate - * @type {number} - */ - get minAutoLevel (): number { - const { levels, config: { minAutoBitrate } } = this; - const len = levels ? levels.length : 0; - - for (let i = 0; i < len; i++) { - const levelNextBitrate = levels[i].realBitrate - ? Math.max(levels[i].realBitrate, levels[i].bitrate) - : levels[i].bitrate; - - if (levelNextBitrate > minAutoBitrate) { - return i; - } - } - - return 0; - } - - /** - * max level selectable in auto mode according to autoLevelCapping - * @type {number} - */ - get maxAutoLevel (): number { - const { levels, autoLevelCapping } = this; - - let maxAutoLevel; - if (autoLevelCapping === -1 && levels && levels.length) { - maxAutoLevel = levels.length - 1; - } else { - maxAutoLevel = autoLevelCapping; - } - - return maxAutoLevel; - } - - /** - * next automatically selected quality level - * @type {number} - */ - get nextAutoLevel (): number { - // ensure next auto level is between min and max auto level - return Math.min(Math.max(this.abrController.nextAutoLevel, this.minAutoLevel), this.maxAutoLevel); - } - - /** - * this setter is used to force next auto level. - * this is useful to force a switch down in auto mode: - * in case of load error on level N, hls.js can set nextAutoLevel to N-1 for example) - * forced value is valid for one fragment. upon succesful frag loading at forced level, - * this value will be resetted to -1 by ABR controller. - * @type {number} - */ - set nextAutoLevel (nextLevel: number) { - this.abrController.nextAutoLevel = Math.max(this.minAutoLevel, nextLevel); - } - - /** - * @type {AudioTrack[]} - */ - // todo(typescript-audioTrackController) - get audioTracks (): any[] { - const audioTrackController = this.audioTrackController; - return audioTrackController ? audioTrackController.audioTracks : []; - } - - /** - * index of the selected audio track (index in audio track lists) - * @type {number} - */ - get audioTrack (): number { - const audioTrackController = this.audioTrackController; - return audioTrackController ? audioTrackController.audioTrack : -1; - } - - /** - * selects an audio track, based on its index in audio track lists - * @type {number} - */ - set audioTrack (audioTrackId: number) { - const audioTrackController = this.audioTrackController; - if (audioTrackController) { - audioTrackController.audioTrack = audioTrackId; - } - } - - /** - * @type {Seconds} - */ - get liveSyncPosition (): number { - return this.streamController.liveSyncPosition; - } - - /** - * get alternate subtitle tracks list from playlist - * @type {SubtitleTrack[]} - */ - // todo(typescript-subtitleTrackController) - get subtitleTracks (): any[] { - const subtitleTrackController = this.subtitleTrackController; - return subtitleTrackController ? subtitleTrackController.subtitleTracks : []; - } - - /** - * index of the selected subtitle track (index in subtitle track lists) - * @type {number} - */ - get subtitleTrack (): number { - const subtitleTrackController = this.subtitleTrackController; - return subtitleTrackController ? subtitleTrackController.subtitleTrack : -1; - } - - /** - * select an subtitle track, based on its index in subtitle track lists - * @type {number} - */ - set subtitleTrack (subtitleTrackId: number) { - const subtitleTrackController = this.subtitleTrackController; - if (subtitleTrackController) { - subtitleTrackController.subtitleTrack = subtitleTrackId; - } - } - - /** - * @type {boolean} - */ - get subtitleDisplay (): boolean { - const subtitleTrackController = this.subtitleTrackController; - return subtitleTrackController ? subtitleTrackController.subtitleDisplay : false; - } - - /** - * Enable/disable subtitle display rendering - * @type {boolean} - */ - set subtitleDisplay (value: boolean) { - const subtitleTrackController = this.subtitleTrackController; - if (subtitleTrackController) { - subtitleTrackController.subtitleDisplay = value; - } - } } + +export default Hls \ No newline at end of file From 9c978be91bc430a559c0587378a2d68f5b23befd Mon Sep 17 00:00:00 2001 From: Trek H Date: Mon, 20 Jan 2020 20:52:37 +1030 Subject: [PATCH 11/15] mjpeg-player: updated player for m3u changes Updated player to play data that has been loaded using the hlsjs m3u loading code. Added an input URL element to html to load playlists. --- cmd/mjpeg-player/index.html | 69 ++++++++++----- cmd/mjpeg-player/main.js | 165 +++++++++++++++++++++++++----------- cmd/mjpeg-player/player.js | 151 +++++++++++++++++++++------------ 3 files changed, 259 insertions(+), 126 deletions(-) diff --git a/cmd/mjpeg-player/index.html b/cmd/mjpeg-player/index.html index 064f436c..60a40429 100644 --- a/cmd/mjpeg-player/index.html +++ b/cmd/mjpeg-player/index.html @@ -1,34 +1,59 @@ + + - - Mjpeg Player - - + + Mjpeg Player + -
-
-
-
- -
-
-
- Frame Rate: fps -
-
- -
+
+
+
+
+
+
+
+ + + +
+
+ Frame Rate: fps +
+
+ +
-
-
- ©2019 Australian Ocean Laboratory Limited (AusOcean) (License) -
-
+
+
+
+ ©2019 Australian Ocean Laboratory Limited (AusOcean) (License) +
+
\ No newline at end of file diff --git a/cmd/mjpeg-player/main.js b/cmd/mjpeg-player/main.js index 037b31ed..6b1883c2 100644 --- a/cmd/mjpeg-player/main.js +++ b/cmd/mjpeg-player/main.js @@ -1,12 +1,9 @@ /* -NAME - main.js - AUTHOR Trek Hopton LICENSE - This file is Copyright (C) 2019 the Australian Ocean Lab (AusOcean) + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) It is free software: you can redistribute it and/or modify them under the terms of the GNU General Public License as published by the @@ -19,57 +16,125 @@ LICENSE for more details. You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. + If not, see http://www.gnu.org/licenses.. */ -// play will process and play the chosen target file. -function play() { - const viewer = document.getElementById('viewer'); - const input = event.target.files[0]; - const reader = new FileReader(); +import Hls from "./hlsjs/hls.js"; - reader.onload = event => { - const player = new Worker("player.js"); +let started = false; +let player, viewer; - let rate = document.getElementById('rate'); - if (rate.value && rate.value > 0) { - player.postMessage({ msg: "setFrameRate", data: rate.value }); - } +function init() { + document.addEventListener('DOMContentLoaded', load); + document.addEventListener('DOMContentLoaded', function () { + document.getElementById('urlBtn').addEventListener('click', load); + document.getElementById('fileInput').addEventListener('change', play); + viewer = document.getElementById('viewer'); + } + ); +} - player.onmessage = e => { - switch (e.data.msg) { - case "frame": - const blob = new Blob([new Uint8Array(e.data.data)], { - type: 'video/x-motion-jpeg' - }); - const url = URL.createObjectURL(blob); - viewer.src = url; - break; - case "log": - console.log(e.data.data); - break; - case "stop": - break; - default: - console.error("unknown message from player"); - break; - } - }; +init(); - switch (input.name.split('.')[1]) { - case "mjpeg": - case "mjpg": - player.postMessage({ msg: "loadMjpeg", data: event.target.result }, [event.target.result]); - break; - case "ts": - player.postMessage({ msg: "loadMtsMjpeg", data: event.target.result }, [event.target.result]); - break; - default: - console.error("unknown file format"); - break; - } - }; - reader.onerror = error => reject(error); - reader.readAsArrayBuffer(input); +// load gets the URL and creates an Hls instance to load the content. +function load() { + let url = document.getElementById('url').value; + if (url == "") { + url = getQuery() + document.getElementById('url').value = url; + } + if (url[0] == '/') { + url = window.location.protocol + '//' + window.location.host + url; + } + if (url == "") { + return; + } + + let hls = new Hls(); + hls.loadSource(url, append); +} + +// getQuery gets everything after the question mark in the URL. +function getQuery() { + let regex = new RegExp("\\?(.*)"); + let match = regex.exec(window.location.href); + if (match == null) { + return ''; + } else { + return decodeURIComponent(match[1].replace(/\+/g, " ")); + } +} + +// append is used as a callback for whenever +function append(data) { + if (!started) { + player = new Worker("player.js"); + + let rate = document.getElementById('rate'); + if (rate.value && rate.value > 0) { + player.postMessage({ msg: "setFrameRate", data: rate.value }); + } + + player.onmessage = handleMessage; + + player.postMessage({ msg: "loadMtsMjpeg", data: data }, [data]); + started = true; + } else { + player.postMessage({ msg: "appendMtsMjpeg", data: data }, [data]); + } } + +// play will process and play the target file chosen with the file input element. +function play() { + const input = event.target.files[0]; + const reader = new FileReader(); + + reader.onload = event => { + const player = new Worker("player.js"); + + let rate = document.getElementById('rate'); + if (rate.value && rate.value > 0) { + player.postMessage({ msg: "setFrameRate", data: rate.value }); + } + + player.onmessage = handleMessage; + + switch (input.name.split('.')[1]) { + case "mjpeg": + case "mjpg": + player.postMessage({ msg: "loadMjpeg", data: event.target.result }, [event.target.result]); + break; + case "ts": + player.postMessage({ msg: "loadMtsMjpeg", data: event.target.result }, [event.target.result]); + break; + default: + console.error("unknown file format"); + break; + } + }; + reader.onerror = error => reject(error); + reader.readAsArrayBuffer(input); +} + +// handleMessage handles messgaes from the player workers, its main job is to update the display when a frame is received. +function handleMessage(e) { + switch (e.data.msg) { + case "frame": + const blob = new Blob([new Uint8Array(e.data.data)], { + type: 'video/x-motion-jpeg' + }); + const url = URL.createObjectURL(blob); + viewer.src = url; + break; + case "log": + console.log(e.data.data); + break; + case "stop": + console.log("stopped"); + break; + default: + console.error("unknown message from player"); + break; + } +} \ No newline at end of file diff --git a/cmd/mjpeg-player/player.js b/cmd/mjpeg-player/player.js index 34ff75ab..a216596e 100644 --- a/cmd/mjpeg-player/player.js +++ b/cmd/mjpeg-player/player.js @@ -1,12 +1,9 @@ /* -NAME - player.js - AUTHOR Trek Hopton LICENSE - This file is Copyright (C) 2019 the Australian Ocean Lab (AusOcean) + This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) It is free software: you can redistribute it and/or modify them under the terms of the GNU General Public License as published by the @@ -22,68 +19,114 @@ LICENSE If not, see http://www.gnu.org/licenses. */ -let frameRate = 30; +let frameRate = 25; +self.importScripts('./lex-mjpeg.js'); +self.importScripts('./hlsjs/mts-demuxer.js'); + +const codecs = { + MJPEG: 1, + MTS_MJPEG: 2, +} onmessage = e => { - switch (e.data.msg) { - case "setFrameRate": - frameRate = e.data.data; - break; - case "loadMjpeg": - self.importScripts('./lex-mjpeg.js'); - let mjpeg = new Uint8Array(e.data.data); - let lex = new MJPEGLexer(mjpeg); - player = new Player(lex); - player.setFrameRate(frameRate); - player.start(); - break; - case "loadMtsMjpeg": - self.importScripts('./hlsjs/mts-demuxer.js'); - let mtsMjpeg = new Uint8Array(e.data.data); - let demux = new MTSDemuxer(); - let tracks = demux._getTracks(); - demux.append(mtsMjpeg); - buf = new FrameBuffer(tracks.video.data); - player = new Player(buf); - player.setFrameRate(frameRate); //TODO: read frame rate from metadata. - player.start(); - break; - default: - console.error("unknown message from main thread"); - break; - } + switch (e.data.msg) { + case "setFrameRate": + frameRate = e.data.data; + break; + case "loadMjpeg": + player = new PlayerWorker(); + player.init(codecs.MJPEG); + player.append(e.data.data); + player.setFrameRate(frameRate); + player.start(); + break; + case "loadMtsMjpeg": + player = new PlayerWorker(); + player.init(codecs.MTS_MJPEG); + player.append(e.data.data); + player.start(); + break; + case "appendMtsMjpeg": + player.append(e.data.data); + break; + default: + console.error("unknown message from main thread"); + break; + } }; -class Player { - constructor(buffer) { - this.buffer = buffer; - this.frameRate = frameRate; +class PlayerWorker { + init(codec) { + this.frameRate = frameRate; + this.codec = codec; + switch (codec) { + case codecs.MJPEG: + this.frameSrc = new MJPEGLexer(); + break; + case codecs.MTS_MJPEG: + this.frameSrc = new FrameBuffer(); + break; } + } - setFrameRate(rate) { - this.frameRate = rate; - } + setFrameRate(rate) { + this.frameRate = rate; + } - start() { - let frame = this.buffer.read(); - if (frame != null) { - postMessage({ msg: "frame", data: frame.buffer }, [frame.buffer]); - setTimeout(() => { this.start(); }, 1000 / this.frameRate); - } else { - postMessage({ msg: "stop" }); - } + start() { + let frame = this.frameSrc.read(); + if (frame != null) { + postMessage({ msg: "frame", data: frame.buffer }, [frame.buffer]); + setTimeout(() => { this.start(); }, 1000 / this.frameRate); + } else { + postMessage({ msg: "stop" }); } + } + + append(data) { + this.frameSrc.append(data); + } } // FrameBuffer allows an array of subarrays (MJPEG frames) to be read one at a time. class FrameBuffer { - constructor(src) { - this.src = src; - this.off = 0; - } + constructor() { + this.segments = []; + this.off = { segment: 0, frame: 0 }; + this.demuxer = new MTSDemuxer(); + } - // read returns the next single frame. - read() { - return this.src[this.off++]; + // read returns the next single frame. + read() { + let off = this.off; + let prevOff = off; + if (this.incrementOff()) { + return this.segments[prevOff.segment][prevOff.frame]; + } else { + return null; } + } + + append(data) { + let demuxed = this.demuxer.demux(new Uint8Array(data)); + this.segments.push(demuxed.data); + } + + incrementOff() { + if (!this.segments || !this.segments[this.off.segment]) { + return false; + } + if (this.off.frame + 1 >= this.segments[this.off.segment].length) { + if (this.off.segment + 1 >= this.segments.length) { + return false; + } else { + this.off.segment++; + this.off.frame = 0; + return true; + } + } else { + this.off.frame++; + return true; + } + } } \ No newline at end of file From 6f583a2815992edf873c78360711c67fef07b431 Mon Sep 17 00:00:00 2001 From: Trek H Date: Tue, 21 Jan 2020 11:47:37 +1030 Subject: [PATCH 12/15] mjpeg-player: comments for classes and functions --- cmd/mjpeg-player/main.js | 9 ++++++--- cmd/mjpeg-player/player.js | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cmd/mjpeg-player/main.js b/cmd/mjpeg-player/main.js index 6b1883c2..e4a0eba0 100644 --- a/cmd/mjpeg-player/main.js +++ b/cmd/mjpeg-player/main.js @@ -24,6 +24,7 @@ import Hls from "./hlsjs/hls.js"; let started = false; let player, viewer; +// init gets DOM elements once the document has been loaded and adds listeners where necessary. function init() { document.addEventListener('DOMContentLoaded', load); document.addEventListener('DOMContentLoaded', function () { @@ -36,7 +37,8 @@ function init() { init(); -// load gets the URL and creates an Hls instance to load the content. +// load gets the URL from the URL input element or the browser's URL bar +// and creates an Hls instance to load the content from the URL. function load() { let url = document.getElementById('url').value; if (url == "") { @@ -54,7 +56,7 @@ function load() { hls.loadSource(url, append); } -// getQuery gets everything after the question mark in the URL. +// getQuery gets everything after the question mark from the URL in the browser's URL bar. function getQuery() { let regex = new RegExp("\\?(.*)"); let match = regex.exec(window.location.href); @@ -65,7 +67,8 @@ function getQuery() { } } -// append is used as a callback for whenever +// append, on the first call, starts a player worker and passes it a frame rate and the video data, +// on subsequent calls it passes the video data to the player worker. function append(data) { if (!started) { player = new Worker("player.js"); diff --git a/cmd/mjpeg-player/player.js b/cmd/mjpeg-player/player.js index a216596e..ea96e031 100644 --- a/cmd/mjpeg-player/player.js +++ b/cmd/mjpeg-player/player.js @@ -19,7 +19,7 @@ LICENSE If not, see http://www.gnu.org/licenses. */ -let frameRate = 25; +let frameRate = 25; //Keeps track of the frame rate, default is 25fps. self.importScripts('./lex-mjpeg.js'); self.importScripts('./hlsjs/mts-demuxer.js'); @@ -28,6 +28,7 @@ const codecs = { MTS_MJPEG: 2, } +// onmessage is called whenever the main thread sends this worker a message. onmessage = e => { switch (e.data.msg) { case "setFrameRate": @@ -55,6 +56,7 @@ onmessage = e => { } }; +// PlayerWorker has a FrameBuffer to hold frames and once started, passes them one at a time to the main thread. class PlayerWorker { init(codec) { this.frameRate = frameRate; From a5170331e4f3d08ffa08c9f79cd949d72924e81f Mon Sep 17 00:00:00 2001 From: Trek H Date: Tue, 21 Jan 2020 13:32:29 +1030 Subject: [PATCH 13/15] mjpeg-player: added original hls.js files for reloading live playlists These have been added in their unmodified state so that when they are modified to work with our code, the changes can easily be seen. --- .../hlsjs/controller/level-controller.js | 468 ++++++++++++++++++ .../hlsjs/controller/level-helper.js | 229 +++++++++ 2 files changed, 697 insertions(+) create mode 100644 cmd/mjpeg-player/hlsjs/controller/level-controller.js create mode 100644 cmd/mjpeg-player/hlsjs/controller/level-helper.js diff --git a/cmd/mjpeg-player/hlsjs/controller/level-controller.js b/cmd/mjpeg-player/hlsjs/controller/level-controller.js new file mode 100644 index 00000000..9360f57f --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/controller/level-controller.js @@ -0,0 +1,468 @@ +/* + * Level Controller +*/ + +import Event from '../events'; +import EventHandler from '../event-handler'; +import { logger } from '../utils/logger'; +import { ErrorTypes, ErrorDetails } from '../errors'; +import { isCodecSupportedInMp4 } from '../utils/codecs'; +import { addGroupId, computeReloadInterval } from './level-helper'; + +const { performance } = window; +let chromeOrFirefox; + +export default class LevelController extends EventHandler { + constructor (hls) { + super(hls, + Event.MANIFEST_LOADED, + Event.LEVEL_LOADED, + Event.AUDIO_TRACK_SWITCHED, + Event.FRAG_LOADED, + Event.ERROR); + + this.canload = false; + this.currentLevelIndex = null; + this.manualLevelIndex = -1; + this.timer = null; + + chromeOrFirefox = /chrome|firefox/.test(navigator.userAgent.toLowerCase()); + } + + onHandlerDestroying () { + this.clearTimer(); + this.manualLevelIndex = -1; + } + + clearTimer () { + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + } + + startLoad () { + let levels = this._levels; + + this.canload = true; + this.levelRetryCount = 0; + + // clean up live level details to force reload them, and reset load errors + if (levels) { + levels.forEach(level => { + level.loadError = 0; + const levelDetails = level.details; + if (levelDetails && levelDetails.live) { + level.details = undefined; + } + }); + } + // speed up live playlist refresh if timer exists + if (this.timer !== null) { + this.loadLevel(); + } + } + + stopLoad () { + this.canload = false; + } + + onManifestLoaded (data) { + let levels = []; + let audioTracks = []; + let bitrateStart; + let levelSet = {}; + let levelFromSet = null; + let videoCodecFound = false; + let audioCodecFound = false; + + // regroup redundant levels together + data.levels.forEach(level => { + const attributes = level.attrs; + level.loadError = 0; + level.fragmentError = false; + + videoCodecFound = videoCodecFound || !!level.videoCodec; + audioCodecFound = audioCodecFound || !!level.audioCodec; + + // erase audio codec info if browser does not support mp4a.40.34. + // demuxer will autodetect codec and fallback to mpeg/audio + if (chromeOrFirefox && level.audioCodec && level.audioCodec.indexOf('mp4a.40.34') !== -1) { + level.audioCodec = undefined; + } + + levelFromSet = levelSet[level.bitrate]; // FIXME: we would also have to match the resolution here + + if (!levelFromSet) { + level.url = [level.url]; + level.urlId = 0; + levelSet[level.bitrate] = level; + levels.push(level); + } else { + levelFromSet.url.push(level.url); + } + + if (attributes) { + if (attributes.AUDIO) { + audioCodecFound = true; + addGroupId(levelFromSet || level, 'audio', attributes.AUDIO); + } + if (attributes.SUBTITLES) { + addGroupId(levelFromSet || level, 'text', attributes.SUBTITLES); + } + } + }); + + // remove audio-only level if we also have levels with audio+video codecs signalled + if (videoCodecFound && audioCodecFound) { + levels = levels.filter(({ videoCodec }) => !!videoCodec); + } + + // only keep levels with supported audio/video codecs + levels = levels.filter(({ audioCodec, videoCodec }) => { + return (!audioCodec || isCodecSupportedInMp4(audioCodec, 'audio')) && (!videoCodec || isCodecSupportedInMp4(videoCodec, 'video')); + }); + + if (data.audioTracks) { + audioTracks = data.audioTracks.filter(track => !track.audioCodec || isCodecSupportedInMp4(track.audioCodec, 'audio')); + // Reassign id's after filtering since they're used as array indices + audioTracks.forEach((track, index) => { + track.id = index; + }); + } + + if (levels.length > 0) { + // start bitrate is the first bitrate of the manifest + bitrateStart = levels[0].bitrate; + // sort level on bitrate + levels.sort((a, b) => a.bitrate - b.bitrate); + this._levels = levels; + // find index of first level in sorted levels + for (let i = 0; i < levels.length; i++) { + if (levels[i].bitrate === bitrateStart) { + this._firstLevel = i; + logger.log(`manifest loaded,${levels.length} level(s) found, first bitrate:${bitrateStart}`); + break; + } + } + + // Audio is only alternate if manifest include a URI along with the audio group tag + this.hls.trigger(Event.MANIFEST_PARSED, { + levels, + audioTracks, + firstLevel: this._firstLevel, + stats: data.stats, + audio: audioCodecFound, + video: videoCodecFound, + altAudio: audioTracks.some(t => !!t.url) + }); + } else { + this.hls.trigger(Event.ERROR, { + type: ErrorTypes.MEDIA_ERROR, + details: ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR, + fatal: true, + url: this.hls.url, + reason: 'no level with compatible codecs found in manifest' + }); + } + } + + get levels () { + return this._levels; + } + + get level () { + return this.currentLevelIndex; + } + + set level (newLevel) { + let levels = this._levels; + if (levels) { + newLevel = Math.min(newLevel, levels.length - 1); + if (this.currentLevelIndex !== newLevel || !levels[newLevel].details) { + this.setLevelInternal(newLevel); + } + } + } + + setLevelInternal (newLevel) { + const levels = this._levels; + const hls = this.hls; + // check if level idx is valid + if (newLevel >= 0 && newLevel < levels.length) { + // stopping live reloading timer if any + this.clearTimer(); + if (this.currentLevelIndex !== newLevel) { + logger.log(`switching to level ${newLevel}`); + this.currentLevelIndex = newLevel; + const levelProperties = levels[newLevel]; + levelProperties.level = newLevel; + hls.trigger(Event.LEVEL_SWITCHING, levelProperties); + } + const level = levels[newLevel]; + const levelDetails = level.details; + + // check if we need to load playlist for this level + if (!levelDetails || levelDetails.live) { + // level not retrieved yet, or live playlist we need to (re)load it + let urlId = level.urlId; + hls.trigger(Event.LEVEL_LOADING, { url: level.url[urlId], level: newLevel, id: urlId }); + } + } else { + // invalid level id given, trigger error + hls.trigger(Event.ERROR, { + type: ErrorTypes.OTHER_ERROR, + details: ErrorDetails.LEVEL_SWITCH_ERROR, + level: newLevel, + fatal: false, + reason: 'invalid level idx' + }); + } + } + + get manualLevel () { + return this.manualLevelIndex; + } + + set manualLevel (newLevel) { + this.manualLevelIndex = newLevel; + if (this._startLevel === undefined) { + this._startLevel = newLevel; + } + + if (newLevel !== -1) { + this.level = newLevel; + } + } + + get firstLevel () { + return this._firstLevel; + } + + set firstLevel (newLevel) { + this._firstLevel = newLevel; + } + + get startLevel () { + // hls.startLevel takes precedence over config.startLevel + // if none of these values are defined, fallback on this._firstLevel (first quality level appearing in variant manifest) + if (this._startLevel === undefined) { + let configStartLevel = this.hls.config.startLevel; + if (configStartLevel !== undefined) { + return configStartLevel; + } else { + return this._firstLevel; + } + } else { + return this._startLevel; + } + } + + set startLevel (newLevel) { + this._startLevel = newLevel; + } + + onError (data) { + if (data.fatal) { + if (data.type === ErrorTypes.NETWORK_ERROR) { + this.clearTimer(); + } + + return; + } + + let levelError = false, fragmentError = false; + let levelIndex; + + // try to recover not fatal errors + switch (data.details) { + case ErrorDetails.FRAG_LOAD_ERROR: + case ErrorDetails.FRAG_LOAD_TIMEOUT: + case ErrorDetails.KEY_LOAD_ERROR: + case ErrorDetails.KEY_LOAD_TIMEOUT: + levelIndex = data.frag.level; + fragmentError = true; + break; + case ErrorDetails.LEVEL_LOAD_ERROR: + case ErrorDetails.LEVEL_LOAD_TIMEOUT: + levelIndex = data.context.level; + levelError = true; + break; + case ErrorDetails.REMUX_ALLOC_ERROR: + levelIndex = data.level; + levelError = true; + break; + } + + if (levelIndex !== undefined) { + this.recoverLevel(data, levelIndex, levelError, fragmentError); + } + } + + /** + * Switch to a redundant stream if any available. + * If redundant stream is not available, emergency switch down if ABR mode is enabled. + * + * @param {Object} errorEvent + * @param {Number} levelIndex current level index + * @param {Boolean} levelError + * @param {Boolean} fragmentError + */ + // FIXME Find a better abstraction where fragment/level retry management is well decoupled + recoverLevel (errorEvent, levelIndex, levelError, fragmentError) { + let { config } = this.hls; + let { details: errorDetails } = errorEvent; + let level = this._levels[levelIndex]; + let redundantLevels, delay, nextLevel; + + level.loadError++; + level.fragmentError = fragmentError; + + if (levelError) { + if ((this.levelRetryCount + 1) <= config.levelLoadingMaxRetry) { + // exponential backoff capped to max retry timeout + delay = Math.min(Math.pow(2, this.levelRetryCount) * config.levelLoadingRetryDelay, config.levelLoadingMaxRetryTimeout); + // Schedule level reload + this.timer = setTimeout(() => this.loadLevel(), delay); + // boolean used to inform stream controller not to switch back to IDLE on non fatal error + errorEvent.levelRetry = true; + this.levelRetryCount++; + logger.warn(`level controller, ${errorDetails}, retry in ${delay} ms, current retry count is ${this.levelRetryCount}`); + } else { + logger.error(`level controller, cannot recover from ${errorDetails} error`); + this.currentLevelIndex = null; + // stopping live reloading timer if any + this.clearTimer(); + // switch error to fatal + errorEvent.fatal = true; + return; + } + } + + // Try any redundant streams if available for both errors: level and fragment + // If level.loadError reaches redundantLevels it means that we tried them all, no hope => let's switch down + if (levelError || fragmentError) { + redundantLevels = level.url.length; + + if (redundantLevels > 1 && level.loadError < redundantLevels) { + level.urlId = (level.urlId + 1) % redundantLevels; + level.details = undefined; + + logger.warn(`level controller, ${errorDetails} for level ${levelIndex}: switching to redundant URL-id ${level.urlId}`); + + // console.log('Current audio track group ID:', this.hls.audioTracks[this.hls.audioTrack].groupId); + // console.log('New video quality level audio group id:', level.attrs.AUDIO); + } else { + // Search for available level + if (this.manualLevelIndex === -1) { + // When lowest level has been reached, let's start hunt from the top + nextLevel = (levelIndex === 0) ? this._levels.length - 1 : levelIndex - 1; + logger.warn(`level controller, ${errorDetails}: switch to ${nextLevel}`); + this.hls.nextAutoLevel = this.currentLevelIndex = nextLevel; + } else if (fragmentError) { + // Allow fragment retry as long as configuration allows. + // reset this._level so that another call to set level() will trigger again a frag load + logger.warn(`level controller, ${errorDetails}: reload a fragment`); + this.currentLevelIndex = null; + } + } + } + } + + // reset errors on the successful load of a fragment + onFragLoaded ({ frag }) { + if (frag !== undefined && frag.type === 'main') { + const level = this._levels[frag.level]; + if (level !== undefined) { + level.fragmentError = false; + level.loadError = 0; + this.levelRetryCount = 0; + } + } + } + + onLevelLoaded (data) { + const { level, details } = data; + // only process level loaded events matching with expected level + if (level !== this.currentLevelIndex) { + return; + } + + const curLevel = this._levels[level]; + // reset level load error counter on successful level loaded only if there is no issues with fragments + if (!curLevel.fragmentError) { + curLevel.loadError = 0; + this.levelRetryCount = 0; + } + // if current playlist is a live playlist, arm a timer to reload it + if (details.live) { + const reloadInterval = computeReloadInterval(curLevel.details, details, data.stats.trequest); + logger.log(`live playlist, reload in ${Math.round(reloadInterval)} ms`); + this.timer = setTimeout(() => this.loadLevel(), reloadInterval); + } else { + this.clearTimer(); + } + } + + onAudioTrackSwitched (data) { + const audioGroupId = this.hls.audioTracks[data.id].groupId; + + const currentLevel = this.hls.levels[this.currentLevelIndex]; + if (!currentLevel) { + return; + } + + if (currentLevel.audioGroupIds) { + let urlId = -1; + + for (let i = 0; i < currentLevel.audioGroupIds.length; i++) { + if (currentLevel.audioGroupIds[i] === audioGroupId) { + urlId = i; + break; + } + } + + if (urlId !== currentLevel.urlId) { + currentLevel.urlId = urlId; + this.startLoad(); + } + } + } + + loadLevel () { + logger.debug('call to loadLevel'); + + if (this.currentLevelIndex !== null && this.canload) { + const levelObject = this._levels[this.currentLevelIndex]; + + if (typeof levelObject === 'object' && + levelObject.url.length > 0) { + const level = this.currentLevelIndex; + const id = levelObject.urlId; + const url = levelObject.url[id]; + + logger.log(`Attempt loading level index ${level} with URL-id ${id}`); + + // console.log('Current audio track group ID:', this.hls.audioTracks[this.hls.audioTrack].groupId); + // console.log('New video quality level audio group id:', levelObject.attrs.AUDIO, level); + + this.hls.trigger(Event.LEVEL_LOADING, { url, level, id }); + } + } + } + + get nextLoadLevel () { + if (this.manualLevelIndex !== -1) { + return this.manualLevelIndex; + } else { + return this.hls.nextAutoLevel; + } + } + + set nextLoadLevel (nextLevel) { + this.level = nextLevel; + if (this.manualLevelIndex === -1) { + this.hls.nextAutoLevel = nextLevel; + } + } +} diff --git a/cmd/mjpeg-player/hlsjs/controller/level-helper.js b/cmd/mjpeg-player/hlsjs/controller/level-helper.js new file mode 100644 index 00000000..23f59c35 --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/controller/level-helper.js @@ -0,0 +1,229 @@ +/** + * @module LevelHelper + * + * Providing methods dealing with playlist sliding and drift + * + * TODO: Create an actual `Level` class/model that deals with all this logic in an object-oriented-manner. + * + * */ + +import { logger } from '../utils/logger'; + +export function addGroupId (level, type, id) { + switch (type) { + case 'audio': + if (!level.audioGroupIds) { + level.audioGroupIds = []; + } + level.audioGroupIds.push(id); + break; + case 'text': + if (!level.textGroupIds) { + level.textGroupIds = []; + } + level.textGroupIds.push(id); + break; + } +} + +export function updatePTS (fragments, fromIdx, toIdx) { + let fragFrom = fragments[fromIdx], fragTo = fragments[toIdx], fragToPTS = fragTo.startPTS; + // if we know startPTS[toIdx] + if (Number.isFinite(fragToPTS)) { + // update fragment duration. + // it helps to fix drifts between playlist reported duration and fragment real duration + if (toIdx > fromIdx) { + fragFrom.duration = fragToPTS - fragFrom.start; + if (fragFrom.duration < 0) { + logger.warn(`negative duration computed for frag ${fragFrom.sn},level ${fragFrom.level}, there should be some duration drift between playlist and fragment!`); + } + } else { + fragTo.duration = fragFrom.start - fragToPTS; + if (fragTo.duration < 0) { + logger.warn(`negative duration computed for frag ${fragTo.sn},level ${fragTo.level}, there should be some duration drift between playlist and fragment!`); + } + } + } else { + // we dont know startPTS[toIdx] + if (toIdx > fromIdx) { + fragTo.start = fragFrom.start + fragFrom.duration; + } else { + fragTo.start = Math.max(fragFrom.start - fragTo.duration, 0); + } + } +} + +export function updateFragPTSDTS (details, frag, startPTS, endPTS, startDTS, endDTS) { + // update frag PTS/DTS + let maxStartPTS = startPTS; + if (Number.isFinite(frag.startPTS)) { + // delta PTS between audio and video + let deltaPTS = Math.abs(frag.startPTS - startPTS); + if (!Number.isFinite(frag.deltaPTS)) { + frag.deltaPTS = deltaPTS; + } else { + frag.deltaPTS = Math.max(deltaPTS, frag.deltaPTS); + } + + maxStartPTS = Math.max(startPTS, frag.startPTS); + startPTS = Math.min(startPTS, frag.startPTS); + endPTS = Math.max(endPTS, frag.endPTS); + startDTS = Math.min(startDTS, frag.startDTS); + endDTS = Math.max(endDTS, frag.endDTS); + } + + const drift = startPTS - frag.start; + frag.start = frag.startPTS = startPTS; + frag.maxStartPTS = maxStartPTS; + frag.endPTS = endPTS; + frag.startDTS = startDTS; + frag.endDTS = endDTS; + frag.duration = endPTS - startPTS; + + const sn = frag.sn; + // exit if sn out of range + if (!details || sn < details.startSN || sn > details.endSN) { + return 0; + } + + let fragIdx, fragments, i; + fragIdx = sn - details.startSN; + fragments = details.fragments; + // update frag reference in fragments array + // rationale is that fragments array might not contain this frag object. + // this will happen if playlist has been refreshed between frag loading and call to updateFragPTSDTS() + // if we don't update frag, we won't be able to propagate PTS info on the playlist + // resulting in invalid sliding computation + fragments[fragIdx] = frag; + // adjust fragment PTS/duration from seqnum-1 to frag 0 + for (i = fragIdx; i > 0; i--) { + updatePTS(fragments, i, i - 1); + } + + // adjust fragment PTS/duration from seqnum to last frag + for (i = fragIdx; i < fragments.length - 1; i++) { + updatePTS(fragments, i, i + 1); + } + + details.PTSKnown = true; + return drift; +} + +export function mergeDetails (oldDetails, newDetails) { + // potentially retrieve cached initsegment + if (newDetails.initSegment && oldDetails.initSegment) { + newDetails.initSegment = oldDetails.initSegment; + } + + // check if old/new playlists have fragments in common + // loop through overlapping SN and update startPTS , cc, and duration if any found + let ccOffset = 0; + let PTSFrag; + mapFragmentIntersection(oldDetails, newDetails, (oldFrag, newFrag) => { + ccOffset = oldFrag.cc - newFrag.cc; + if (Number.isFinite(oldFrag.startPTS)) { + newFrag.start = newFrag.startPTS = oldFrag.startPTS; + newFrag.endPTS = oldFrag.endPTS; + newFrag.duration = oldFrag.duration; + newFrag.backtracked = oldFrag.backtracked; + newFrag.dropped = oldFrag.dropped; + PTSFrag = newFrag; + } + // PTS is known when there are overlapping segments + newDetails.PTSKnown = true; + }); + + if (!newDetails.PTSKnown) { + return; + } + + if (ccOffset) { + logger.log('discontinuity sliding from playlist, take drift into account'); + const newFragments = newDetails.fragments; + for (let i = 0; i < newFragments.length; i++) { + newFragments[i].cc += ccOffset; + } + } + + // if at least one fragment contains PTS info, recompute PTS information for all fragments + if (PTSFrag) { + updateFragPTSDTS(newDetails, PTSFrag, PTSFrag.startPTS, PTSFrag.endPTS, PTSFrag.startDTS, PTSFrag.endDTS); + } else { + // ensure that delta is within oldFragments range + // also adjust sliding in case delta is 0 (we could have old=[50-60] and new=old=[50-61]) + // in that case we also need to adjust start offset of all fragments + adjustSliding(oldDetails, newDetails); + } + // if we are here, it means we have fragments overlapping between + // old and new level. reliable PTS info is thus relying on old level + newDetails.PTSKnown = oldDetails.PTSKnown; +} + +export function mergeSubtitlePlaylists (oldPlaylist, newPlaylist, referenceStart = 0) { + let lastIndex = -1; + mapFragmentIntersection(oldPlaylist, newPlaylist, (oldFrag, newFrag, index) => { + newFrag.start = oldFrag.start; + lastIndex = index; + }); + + const frags = newPlaylist.fragments; + if (lastIndex < 0) { + frags.forEach(frag => { + frag.start += referenceStart; + }); + return; + } + + for (let i = lastIndex + 1; i < frags.length; i++) { + frags[i].start = (frags[i - 1].start + frags[i - 1].duration); + } +} + +export function mapFragmentIntersection (oldPlaylist, newPlaylist, intersectionFn) { + if (!oldPlaylist || !newPlaylist) { + return; + } + + const start = Math.max(oldPlaylist.startSN, newPlaylist.startSN) - newPlaylist.startSN; + const end = Math.min(oldPlaylist.endSN, newPlaylist.endSN) - newPlaylist.startSN; + const delta = newPlaylist.startSN - oldPlaylist.startSN; + + for (let i = start; i <= end; i++) { + const oldFrag = oldPlaylist.fragments[delta + i]; + const newFrag = newPlaylist.fragments[i]; + if (!oldFrag || !newFrag) { + break; + } + intersectionFn(oldFrag, newFrag, i); + } +} + +export function adjustSliding (oldPlaylist, newPlaylist) { + const delta = newPlaylist.startSN - oldPlaylist.startSN; + const oldFragments = oldPlaylist.fragments; + const newFragments = newPlaylist.fragments; + + if (delta < 0 || delta > oldFragments.length) { + return; + } + for (let i = 0; i < newFragments.length; i++) { + newFragments[i].start += oldFragments[delta].start; + } +} + +export function computeReloadInterval (currentPlaylist, newPlaylist, lastRequestTime) { + let reloadInterval = 1000 * (newPlaylist.averagetargetduration ? newPlaylist.averagetargetduration : newPlaylist.targetduration); + const minReloadInterval = reloadInterval / 2; + if (currentPlaylist && newPlaylist.endSN === currentPlaylist.endSN) { + // follow HLS Spec, If the client reloads a Playlist file and finds that it has not + // changed then it MUST wait for a period of one-half the target + // duration before retrying. + reloadInterval = minReloadInterval; + } + + if (lastRequestTime) { + reloadInterval = Math.max(minReloadInterval, reloadInterval - (window.performance.now() - lastRequestTime)); + } + // in any case, don't reload more than half of target duration + return Math.round(reloadInterval); +} From 6ed6d1c28b41065d342742bc16d7eaca6eb25c3f Mon Sep 17 00:00:00 2001 From: Trek H Date: Fri, 24 Jan 2020 14:10:42 +1030 Subject: [PATCH 14/15] mjpeg-player: removed files from branch that are changed in existing PRs --- cmd/mjpeg-player/eventemitter3/index.js | 331 --------------- cmd/mjpeg-player/hlsjs/config.js | 101 ----- .../hlsjs/controller/stream-controller.js | 109 ----- cmd/mjpeg-player/hlsjs/event-handler.js | 105 ----- cmd/mjpeg-player/hlsjs/events.js | 55 --- cmd/mjpeg-player/hlsjs/hls.js | 50 --- .../hlsjs/loader/fragment-loader.js | 137 ------- cmd/mjpeg-player/hlsjs/loader/fragment.js | 222 ---------- cmd/mjpeg-player/hlsjs/loader/level-key.js | 47 --- cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js | 388 ------------------ .../hlsjs/loader/playlist-loader.js | 343 ---------------- cmd/mjpeg-player/hlsjs/mts-demuxer.js | 369 ----------------- cmd/mjpeg-player/hlsjs/observer.js | 38 -- cmd/mjpeg-player/hlsjs/types/loader.js | 42 -- cmd/mjpeg-player/hlsjs/utils/codecs.js | 98 ----- cmd/mjpeg-player/hlsjs/utils/xhr-loader.js | 189 --------- cmd/mjpeg-player/index.html | 59 --- cmd/mjpeg-player/main.js | 143 ------- cmd/mjpeg-player/player.js | 134 ------ cmd/mjpeg-player/url-toolkit/url-toolkit.js | 149 ------- 20 files changed, 3109 deletions(-) delete mode 100644 cmd/mjpeg-player/eventemitter3/index.js delete mode 100644 cmd/mjpeg-player/hlsjs/config.js delete mode 100644 cmd/mjpeg-player/hlsjs/controller/stream-controller.js delete mode 100644 cmd/mjpeg-player/hlsjs/event-handler.js delete mode 100644 cmd/mjpeg-player/hlsjs/events.js delete mode 100644 cmd/mjpeg-player/hlsjs/hls.js delete mode 100644 cmd/mjpeg-player/hlsjs/loader/fragment-loader.js delete mode 100644 cmd/mjpeg-player/hlsjs/loader/fragment.js delete mode 100644 cmd/mjpeg-player/hlsjs/loader/level-key.js delete mode 100644 cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js delete mode 100644 cmd/mjpeg-player/hlsjs/loader/playlist-loader.js delete mode 100644 cmd/mjpeg-player/hlsjs/mts-demuxer.js delete mode 100644 cmd/mjpeg-player/hlsjs/observer.js delete mode 100644 cmd/mjpeg-player/hlsjs/types/loader.js delete mode 100644 cmd/mjpeg-player/hlsjs/utils/codecs.js delete mode 100644 cmd/mjpeg-player/hlsjs/utils/xhr-loader.js delete mode 100644 cmd/mjpeg-player/index.html delete mode 100644 cmd/mjpeg-player/main.js delete mode 100644 cmd/mjpeg-player/player.js delete mode 100644 cmd/mjpeg-player/url-toolkit/url-toolkit.js diff --git a/cmd/mjpeg-player/eventemitter3/index.js b/cmd/mjpeg-player/eventemitter3/index.js deleted file mode 100644 index 7d65945d..00000000 --- a/cmd/mjpeg-player/eventemitter3/index.js +++ /dev/null @@ -1,331 +0,0 @@ -'use strict'; - -var has = Object.prototype.hasOwnProperty - , prefix = '~'; - -/** - * Constructor to create a storage for our `EE` objects. - * An `Events` instance is a plain object whose properties are event names. - * - * @constructor - * @private - */ -function Events() { } - -// -// We try to not inherit from `Object.prototype`. In some engines creating an -// instance in this way is faster than calling `Object.create(null)` directly. -// If `Object.create(null)` is not supported we prefix the event names with a -// character to make sure that the built-in object properties are not -// overridden or used as an attack vector. -// -if (Object.create) { - Events.prototype = Object.create(null); - - // - // This hack is needed because the `__proto__` property is still inherited in - // some old browsers like Android 4, iPhone 5.1, Opera 11 and Safari 5. - // - if (!new Events().__proto__) prefix = false; -} - -/** - * Representation of a single event listener. - * - * @param {Function} fn The listener function. - * @param {*} context The context to invoke the listener with. - * @param {Boolean} [once=false] Specify if the listener is a one-time listener. - * @constructor - * @private - */ -function EE(fn, context, once) { - this.fn = fn; - this.context = context; - this.once = once || false; -} - -/** - * Add a listener for a given event. - * - * @param {EventEmitter} emitter Reference to the `EventEmitter` instance. - * @param {(String|Symbol)} event The event name. - * @param {Function} fn The listener function. - * @param {*} context The context to invoke the listener with. - * @param {Boolean} once Specify if the listener is a one-time listener. - * @returns {EventEmitter} - * @private - */ -function addListener(emitter, event, fn, context, once) { - if (typeof fn !== 'function') { - throw new TypeError('The listener must be a function'); - } - - var listener = new EE(fn, context || emitter, once) - , evt = prefix ? prefix + event : event; - - if (!emitter._events[evt]) emitter._events[evt] = listener, emitter._eventsCount++; - else if (!emitter._events[evt].fn) emitter._events[evt].push(listener); - else emitter._events[evt] = [emitter._events[evt], listener]; - - return emitter; -} - -/** - * Clear event by name. - * - * @param {EventEmitter} emitter Reference to the `EventEmitter` instance. - * @param {(String|Symbol)} evt The Event name. - * @private - */ -function clearEvent(emitter, evt) { - if (--emitter._eventsCount === 0) emitter._events = new Events(); - else delete emitter._events[evt]; -} - -/** - * Minimal `EventEmitter` interface that is molded against the Node.js - * `EventEmitter` interface. - * - * @constructor - * @public - */ -function EventEmitter() { - this._events = new Events(); - this._eventsCount = 0; -} - -/** - * Return an array listing the events for which the emitter has registered - * listeners. - * - * @returns {Array} - * @public - */ -EventEmitter.prototype.eventNames = function eventNames() { - var names = [] - , events - , name; - - if (this._eventsCount === 0) return names; - - for (name in (events = this._events)) { - if (has.call(events, name)) names.push(prefix ? name.slice(1) : name); - } - - if (Object.getOwnPropertySymbols) { - return names.concat(Object.getOwnPropertySymbols(events)); - } - - return names; -}; - -/** - * Return the listeners registered for a given event. - * - * @param {(String|Symbol)} event The event name. - * @returns {Array} The registered listeners. - * @public - */ -EventEmitter.prototype.listeners = function listeners(event) { - var evt = prefix ? prefix + event : event - , handlers = this._events[evt]; - - if (!handlers) return []; - if (handlers.fn) return [handlers.fn]; - - for (var i = 0, l = handlers.length, ee = new Array(l); i < l; i++) { - ee[i] = handlers[i].fn; - } - - return ee; -}; - -/** - * Return the number of listeners listening to a given event. - * - * @param {(String|Symbol)} event The event name. - * @returns {Number} The number of listeners. - * @public - */ -EventEmitter.prototype.listenerCount = function listenerCount(event) { - var evt = prefix ? prefix + event : event - , listeners = this._events[evt]; - - if (!listeners) return 0; - if (listeners.fn) return 1; - return listeners.length; -}; - -/** - * Calls each of the listeners registered for a given event. - * - * @param {(String|Symbol)} event The event name. - * @returns {Boolean} `true` if the event had listeners, else `false`. - * @public - */ -EventEmitter.prototype.emit = function emit(event, a1, a2, a3, a4, a5) { - var evt = prefix ? prefix + event : event; - - if (!this._events[evt]) return false; - - var listeners = this._events[evt] - , len = arguments.length - , args - , i; - - if (listeners.fn) { - if (listeners.once) this.removeListener(event, listeners.fn, undefined, true); - - switch (len) { - case 1: return listeners.fn.call(listeners.context), true; - case 2: return listeners.fn.call(listeners.context, a1), true; - case 3: return listeners.fn.call(listeners.context, a1, a2), true; - case 4: return listeners.fn.call(listeners.context, a1, a2, a3), true; - case 5: return listeners.fn.call(listeners.context, a1, a2, a3, a4), true; - case 6: return listeners.fn.call(listeners.context, a1, a2, a3, a4, a5), true; - } - - for (i = 1, args = new Array(len - 1); i < len; i++) { - args[i - 1] = arguments[i]; - } - - listeners.fn.apply(listeners.context, args); - } else { - var length = listeners.length - , j; - - for (i = 0; i < length; i++) { - if (listeners[i].once) this.removeListener(event, listeners[i].fn, undefined, true); - - switch (len) { - case 1: listeners[i].fn.call(listeners[i].context); break; - case 2: listeners[i].fn.call(listeners[i].context, a1); break; - case 3: listeners[i].fn.call(listeners[i].context, a1, a2); break; - case 4: listeners[i].fn.call(listeners[i].context, a1, a2, a3); break; - default: - if (!args) for (j = 1, args = new Array(len - 1); j < len; j++) { - args[j - 1] = arguments[j]; - } - - listeners[i].fn.apply(listeners[i].context, args); - } - } - } - - return true; -}; - -/** - * Add a listener for a given event. - * - * @param {(String|Symbol)} event The event name. - * @param {Function} fn The listener function. - * @param {*} [context=this] The context to invoke the listener with. - * @returns {EventEmitter} `this`. - * @public - */ -EventEmitter.prototype.on = function on(event, fn, context) { - return addListener(this, event, fn, context, false); -}; - -/** - * Add a one-time listener for a given event. - * - * @param {(String|Symbol)} event The event name. - * @param {Function} fn The listener function. - * @param {*} [context=this] The context to invoke the listener with. - * @returns {EventEmitter} `this`. - * @public - */ -EventEmitter.prototype.once = function once(event, fn, context) { - return addListener(this, event, fn, context, true); -}; - -/** - * Remove the listeners of a given event. - * - * @param {(String|Symbol)} event The event name. - * @param {Function} fn Only remove the listeners that match this function. - * @param {*} context Only remove the listeners that have this context. - * @param {Boolean} once Only remove one-time listeners. - * @returns {EventEmitter} `this`. - * @public - */ -EventEmitter.prototype.removeListener = function removeListener(event, fn, context, once) { - var evt = prefix ? prefix + event : event; - - if (!this._events[evt]) return this; - if (!fn) { - clearEvent(this, evt); - return this; - } - - var listeners = this._events[evt]; - - if (listeners.fn) { - if ( - listeners.fn === fn && - (!once || listeners.once) && - (!context || listeners.context === context) - ) { - clearEvent(this, evt); - } - } else { - for (var i = 0, events = [], length = listeners.length; i < length; i++) { - if ( - listeners[i].fn !== fn || - (once && !listeners[i].once) || - (context && listeners[i].context !== context) - ) { - events.push(listeners[i]); - } - } - - // - // Reset the array, or remove it completely if we have no more listeners. - // - if (events.length) this._events[evt] = events.length === 1 ? events[0] : events; - else clearEvent(this, evt); - } - - return this; -}; - -/** - * Remove all listeners, or those of the specified event. - * - * @param {(String|Symbol)} [event] The event name. - * @returns {EventEmitter} `this`. - * @public - */ -EventEmitter.prototype.removeAllListeners = function removeAllListeners(event) { - var evt; - - if (event) { - evt = prefix ? prefix + event : event; - if (this._events[evt]) clearEvent(this, evt); - } else { - this._events = new Events(); - this._eventsCount = 0; - } - - return this; -}; - -// -// Alias methods names because people roll like that. -// -EventEmitter.prototype.off = EventEmitter.prototype.removeListener; -EventEmitter.prototype.addListener = EventEmitter.prototype.on; - -// -// Expose the prefix. -// -EventEmitter.prefixed = prefix; - -// -// Allow `EventEmitter` to be imported as module namespace. -// -EventEmitter.EventEmitter = EventEmitter; - -export default EventEmitter; diff --git a/cmd/mjpeg-player/hlsjs/config.js b/cmd/mjpeg-player/hlsjs/config.js deleted file mode 100644 index ed548ff9..00000000 --- a/cmd/mjpeg-player/hlsjs/config.js +++ /dev/null @@ -1,101 +0,0 @@ -/* -AUTHOR - Trek Hopton - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -import XhrLoader from './utils/xhr-loader.js'; - -// If possible, keep hlsDefaultConfig shallow -// It is cloned whenever a new Hls instance is created, by keeping the config -// shallow the properties are cloned, and we don't end up manipulating the default -export const hlsDefaultConfig = { - autoStartLoad: true, // used by stream-controller - startPosition: -1, // used by stream-controller - defaultAudioCodec: void 0, // used by stream-controller - debug: false, // used by logger - capLevelOnFPSDrop: false, // used by fps-controller - capLevelToPlayerSize: false, // used by cap-level-controller - initialLiveManifestSize: 1, // used by stream-controller - maxBufferLength: 30, // used by stream-controller - maxBufferSize: 60 * 1000 * 1000, // used by stream-controller - maxBufferHole: 0.5, // used by stream-controller - - lowBufferWatchdogPeriod: 0.5, // used by stream-controller - highBufferWatchdogPeriod: 3, // used by stream-controller - nudgeOffset: 0.1, // used by stream-controller - nudgeMaxRetry: 3, // used by stream-controller - maxFragLookUpTolerance: 0.25, // used by stream-controller - liveSyncDurationCount: 3, // used by stream-controller - liveMaxLatencyDurationCount: Infinity, // used by stream-controller - liveSyncDuration: void 0, // used by stream-controller - liveMaxLatencyDuration: void 0, // used by stream-controller - liveDurationInfinity: false, // used by buffer-controller - liveBackBufferLength: Infinity, // used by buffer-controller - maxMaxBufferLength: 600, // used by stream-controller - enableWorker: true, // used by demuxer - enableSoftwareAES: true, // used by decrypter - manifestLoadingTimeOut: 10000, // used by playlist-loader - manifestLoadingMaxRetry: 1, // used by playlist-loader - manifestLoadingRetryDelay: 1000, // used by playlist-loader - manifestLoadingMaxRetryTimeout: 64000, // used by playlist-loader - startLevel: void 0, // used by level-controller - levelLoadingTimeOut: 10000, // used by playlist-loader - levelLoadingMaxRetry: 4, // used by playlist-loader - levelLoadingRetryDelay: 1000, // used by playlist-loader - levelLoadingMaxRetryTimeout: 64000, // used by playlist-loader - fragLoadingTimeOut: 20000, // used by fragment-loader - fragLoadingMaxRetry: 6, // used by fragment-loader - fragLoadingRetryDelay: 1000, // used by fragment-loader - fragLoadingMaxRetryTimeout: 64000, // used by fragment-loader - startFragPrefetch: false, // used by stream-controller - fpsDroppedMonitoringPeriod: 5000, // used by fps-controller - fpsDroppedMonitoringThreshold: 0.2, // used by fps-controller - appendErrorMaxRetry: 3, // used by buffer-controller - loader: XhrLoader, - // loader: FetchLoader, - fLoader: void 0, // used by fragment-loader - pLoader: void 0, // used by playlist-loader - xhrSetup: void 0, // used by xhr-loader - licenseXhrSetup: void 0, // used by eme-controller - fetchSetup: void 0, - // abrController: AbrController, - // bufferController: BufferController, - // capLevelController: CapLevelController, - // fpsController: FPSController, - stretchShortVideoTrack: false, // used by mp4-remuxer - maxAudioFramesDrift: 1, // used by mp4-remuxer - forceKeyFrameOnDiscontinuity: true, // used by ts-demuxer - abrEwmaFastLive: 3, // used by abr-controller - abrEwmaSlowLive: 9, // used by abr-controller - abrEwmaFastVoD: 3, // used by abr-controller - abrEwmaSlowVoD: 9, // used by abr-controller - abrEwmaDefaultEstimate: 5e5, // 500 kbps // used by abr-controller - abrBandWidthFactor: 0.95, // used by abr-controller - abrBandWidthUpFactor: 0.7, // used by abr-controller - abrMaxWithRealBitrate: false, // used by abr-controller - maxStarvationDelay: 4, // used by abr-controller - maxLoadingDelay: 4, // used by abr-controller - minAutoBitrate: 0, // used by hls - emeEnabled: false, // used by eme-controller - widevineLicenseUrl: void 0, // used by eme-controller - // requestMediaKeySystemAccessFunc: requestMediaKeySystemAccess, // used by eme-controller - -}; \ No newline at end of file diff --git a/cmd/mjpeg-player/hlsjs/controller/stream-controller.js b/cmd/mjpeg-player/hlsjs/controller/stream-controller.js deleted file mode 100644 index 31fd0828..00000000 --- a/cmd/mjpeg-player/hlsjs/controller/stream-controller.js +++ /dev/null @@ -1,109 +0,0 @@ -/* -AUTHOR - Trek Hopton - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -/* - * Stream Controller -*/ - -import Event from '../events.js'; -import EventHandler from '../event-handler.js'; - -class StreamController extends EventHandler { - constructor(hls) { - super(hls, - Event.LEVEL_LOADED, - Event.FRAG_LOADED); - this.hls = hls; - this.config = hls.config; - this.audioCodecSwap = false; - this.stallReported = false; - this.gapController = null; - this.currentFrag = 0; - } - - _fetchPayloadOrEos(levelDetails) { - this.fragments = levelDetails.fragments; - this._loadFragment(); - } - - _loadFragment() { - let fragLen = this.fragments.length; - if (this.currentFrag >= fragLen) { - return; - } - this.hls.trigger(Event.FRAG_LOADING, { frag: this.fragments[this.currentFrag++] }); - } - - onLevelLoaded(data) { - const newDetails = data.details; - const newLevelId = data.level; - const levelDetails = data.details; - const duration = newDetails.totalduration; - let sliding = 0; - - console.log(`level ${newLevelId} loaded [${newDetails.startSN},${newDetails.endSN}],duration:${duration}`); - - if (newDetails.live) { - console.log("handling of this case is not implemented"); - } else { - newDetails.PTSKnown = false; - } - // override level info - this.levelLastLoaded = newLevelId; - this.hls.trigger(Event.LEVEL_UPDATED, { details: newDetails, level: newLevelId }); - - if (this.startFragRequested === false) { - // compute start position if set to -1. use it straight away if value is defined - if (this.startPosition === -1 || this.lastCurrentTime === -1) { - // first, check if start time offset has been set in playlist, if yes, use this value - let startTimeOffset = newDetails.startTimeOffset; - if (Number.isFinite(startTimeOffset)) { - if (startTimeOffset < 0) { - console.log(`negative start time offset ${startTimeOffset}, count from end of last fragment`); - startTimeOffset = sliding + duration + startTimeOffset; - } - console.log(`start time offset found in playlist, adjust startPosition to ${startTimeOffset}`); - this.startPosition = startTimeOffset; - } else { - // if live playlist, set start position to be fragment N-this.config.liveSyncDurationCount (usually 3) - if (newDetails.live) { - console.log("handling of this case is not implemented"); - } else { - this.startPosition = 0; - } - } - this.lastCurrentTime = this.startPosition; - } - this.nextLoadPosition = this.startPosition; - } - - this._fetchPayloadOrEos(levelDetails); - } - - onFragLoaded(data) { - this.hls.loadSuccess(data.payload); - this._loadFragment(); - - } -} -export default StreamController; diff --git a/cmd/mjpeg-player/hlsjs/event-handler.js b/cmd/mjpeg-player/hlsjs/event-handler.js deleted file mode 100644 index 24735225..00000000 --- a/cmd/mjpeg-player/hlsjs/event-handler.js +++ /dev/null @@ -1,105 +0,0 @@ -/* -AUTHOR - Trek Hopton - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -/* -* -* All objects in the event handling chain should inherit from this class -* -*/ -import Event from './events.js'; - -const FORBIDDEN_EVENT_NAMES = { - 'hlsEventGeneric': true, - 'hlsHandlerDestroying': true, - 'hlsHandlerDestroyed': true -}; - -class EventHandler { - constructor(hls, ...events) { - this.hls = hls; - this.onEvent = this.onEvent.bind(this); - this.handledEvents = events; - this.useGenericHandler = true; - - this.registerListeners(); - } - - destroy() { - this.onHandlerDestroying(); - this.unregisterListeners(); - this.onHandlerDestroyed(); - } - - onHandlerDestroying() { } - onHandlerDestroyed() { } - - isEventHandler() { - return typeof this.handledEvents === 'object' && this.handledEvents.length && typeof this.onEvent === 'function'; - } - - registerListeners() { - if (this.isEventHandler()) { - this.handledEvents.forEach(function (event) { - if (FORBIDDEN_EVENT_NAMES[event]) { - throw new Error('Forbidden event-name: ' + event); - } - - this.hls.on(event, this.onEvent); - }, this); - } - } - - unregisterListeners() { - if (this.isEventHandler()) { - this.handledEvents.forEach(function (event) { - this.hls.off(event, this.onEvent); - }, this); - } - } - - /** - * arguments: event (string), data (any) - */ - onEvent(event, data) { - this.onEventGeneric(event, data); - } - - onEventGeneric(event, data) { - let eventToFunction = function (event, data) { - let funcName = 'on' + event.replace('hls', ''); - if (typeof this[funcName] !== 'function') { - throw new Error(`Event ${event} has no generic handler in this ${this.constructor.name} class (tried ${funcName})`); - } - - return this[funcName].bind(this, data); - }; - try { - eventToFunction.call(this, event, data).call(); - } catch (err) { - console.error(`An internal error happened while handling event ${event}. Error message: "${err.message}". Here is a stacktrace:`, err); - this.hls.trigger(Event.ERROR, { type: ErrorTypes.OTHER_ERROR, details: ErrorDetails.INTERNAL_EXCEPTION, fatal: false, event: event, err: err }); - } - } -} - -export default EventHandler; diff --git a/cmd/mjpeg-player/hlsjs/events.js b/cmd/mjpeg-player/hlsjs/events.js deleted file mode 100644 index e71d71c5..00000000 --- a/cmd/mjpeg-player/hlsjs/events.js +++ /dev/null @@ -1,55 +0,0 @@ -/* -AUTHOR - Trek Hopton - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -/** - * @readonly - * @enum {string} - */ -const HlsEvents = { - // fired to signal that a manifest loading starts - data: { url : manifestURL} - MANIFEST_LOADING: 'hlsManifestLoading', - // fired after manifest has been loaded - data: { levels : [available quality levels], audioTracks : [ available audio tracks], url : manifestURL, stats : { trequest, tfirst, tload, mtime}} - MANIFEST_LOADED: 'hlsManifestLoaded', - // fired when a level playlist loading starts - data: { url : level URL, level : id of level being loaded} - LEVEL_LOADING: 'hlsLevelLoading', - // fired when a level playlist loading finishes - data: { details : levelDetails object, level : id of loaded level, stats : { trequest, tfirst, tload, mtime} } - LEVEL_LOADED: 'hlsLevelLoaded', - // fired when a level's details have been updated based on previous details, after it has been loaded - data: { details : levelDetails object, level : id of updated level } - LEVEL_UPDATED: 'hlsLevelUpdated', - // fired when an audio track loading starts - data: { url : audio track URL, id : audio track id } - AUDIO_TRACK_LOADING: 'hlsAudioTrackLoading', - // fired when an audio track loading finishes - data: { details : levelDetails object, id : audio track id, stats : { trequest, tfirst, tload, mtime } } - AUDIO_TRACK_LOADED: 'hlsAudioTrackLoaded', - // fired when a subtitle track loading starts - data: { url : subtitle track URL, id : subtitle track id } - SUBTITLE_TRACK_LOADING: 'hlsSubtitleTrackLoading', - // fired when a subtitle track loading finishes - data: { details : levelDetails object, id : subtitle track id, stats : { trequest, tfirst, tload, mtime } } - SUBTITLE_TRACK_LOADED: 'hlsSubtitleTrackLoaded', - // fired when a fragment loading starts - data: { frag : fragment object } - FRAG_LOADING: 'hlsFragLoading', - // fired when a fragment loading is progressing - data: { frag : fragment object, { trequest, tfirst, loaded } } - FRAG_LOAD_PROGRESS: 'hlsFragLoadProgress', - // fired when a fragment loading is completed - data: { frag : fragment object, payload : fragment payload, stats : { trequest, tfirst, tload, length } } - FRAG_LOADED: 'hlsFragLoaded' -}; - -export default HlsEvents; diff --git a/cmd/mjpeg-player/hlsjs/hls.js b/cmd/mjpeg-player/hlsjs/hls.js deleted file mode 100644 index a344d330..00000000 --- a/cmd/mjpeg-player/hlsjs/hls.js +++ /dev/null @@ -1,50 +0,0 @@ -/* -AUTHOR - Trek Hopton - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -import URLToolkit from '../url-toolkit/url-toolkit.js'; -import HlsEvents from './events.js'; -import PlaylistLoader from './loader/playlist-loader.js'; -import FragmentLoader from './loader/fragment-loader.js'; -import StreamController from './controller/stream-controller.js'; -import { hlsDefaultConfig } from './config.js'; -import { Observer } from './observer.js'; - -class Hls extends Observer { - constructor() { - super(); - this.pLoader = new PlaylistLoader(this); - this.streamController = new StreamController(this); - this.fragmentLoader = new FragmentLoader(this); - - this.config = hlsDefaultConfig; - } - - // url is the source URL. Can be relative or absolute. - loadSource(url, callback) { - this.loadSuccess = callback; - url = URLToolkit.buildAbsoluteURL(window.location.href, url, { alwaysNormalize: true }); - this.trigger(HlsEvents.MANIFEST_LOADING, { url: url }); - } -} - -export default Hls \ No newline at end of file diff --git a/cmd/mjpeg-player/hlsjs/loader/fragment-loader.js b/cmd/mjpeg-player/hlsjs/loader/fragment-loader.js deleted file mode 100644 index b7e1f9e1..00000000 --- a/cmd/mjpeg-player/hlsjs/loader/fragment-loader.js +++ /dev/null @@ -1,137 +0,0 @@ -/* -AUTHOR - Trek Hopton - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -/* - * Fragment Loader -*/ - -import Event from '../events.js'; -import EventHandler from '../event-handler.js'; - -class FragmentLoader extends EventHandler { - constructor(hls) { - super(hls, Event.FRAG_LOADING); - this.loaders = {}; - } - - destroy() { - let loaders = this.loaders; - for (let loaderName in loaders) { - let loader = loaders[loaderName]; - if (loader) { - loader.destroy(); - } - } - this.loaders = {}; - - super.destroy(); - } - - onFragLoading(data) { - const frag = data.frag, - type = frag.type, - loaders = this.loaders, - config = this.hls.config, - FragmentILoader = config.fLoader, - DefaultILoader = config.loader; - - // reset fragment state - frag.loaded = 0; - - let loader = loaders[type]; - if (loader) { - console.warn(`abort previous fragment loader for type: ${type}`); - loader.abort(); - } - - loader = loaders[type] = frag.loader = - config.fLoader ? new FragmentILoader(config) : new DefaultILoader(config); - - let loaderContext, loaderConfig, loaderCallbacks; - - loaderContext = { url: frag.url, frag: frag, responseType: 'arraybuffer', progressData: false }; - - let start = frag.byteRangeStartOffset, - end = frag.byteRangeEndOffset; - - if (Number.isFinite(start) && Number.isFinite(end)) { - loaderContext.rangeStart = start; - loaderContext.rangeEnd = end; - } - - loaderConfig = { - timeout: config.fragLoadingTimeOut, - maxRetry: 0, - retryDelay: 0, - maxRetryDelay: config.fragLoadingMaxRetryTimeout - }; - - loaderCallbacks = { - onSuccess: this.loadsuccess.bind(this), - onError: this.loaderror.bind(this), - onTimeout: this.loadtimeout.bind(this), - onProgress: this.loadprogress.bind(this) - }; - - loader.load(loaderContext, loaderConfig, loaderCallbacks); - } - - loadsuccess(response, stats, context, networkDetails = null) { - let payload = response.data, frag = context.frag; - // detach fragment loader on load success - frag.loader = undefined; - this.loaders[frag.type] = undefined; - this.hls.trigger(Event.FRAG_LOADED, { payload: payload, frag: frag, stats: stats, networkDetails: networkDetails }); - } - - loaderror(response, context, networkDetails = null) { - const frag = context.frag; - let loader = frag.loader; - if (loader) { - loader.abort(); - } - - this.loaders[frag.type] = undefined; - this.hls.trigger(Event.ERROR, { type: ErrorTypes.NETWORK_ERROR, details: ErrorDetails.FRAG_LOAD_ERROR, fatal: false, frag: context.frag, response: response, networkDetails: networkDetails }); - } - - loadtimeout(stats, context, networkDetails = null) { - const frag = context.frag; - let loader = frag.loader; - if (loader) { - loader.abort(); - } - - this.loaders[frag.type] = undefined; - this.hls.trigger(Event.ERROR, { type: ErrorTypes.NETWORK_ERROR, details: ErrorDetails.FRAG_LOAD_TIMEOUT, fatal: false, frag: context.frag, networkDetails: networkDetails }); - } - - // data will be used for progressive parsing - loadprogress(stats, context, data, networkDetails = null) { // jshint ignore:line - let frag = context.frag; - frag.loaded = stats.loaded; - this.hls.trigger(Event.FRAG_LOAD_PROGRESS, { frag: frag, stats: stats, networkDetails: networkDetails }); - } -} - -export default FragmentLoader; diff --git a/cmd/mjpeg-player/hlsjs/loader/fragment.js b/cmd/mjpeg-player/hlsjs/loader/fragment.js deleted file mode 100644 index 01fb0509..00000000 --- a/cmd/mjpeg-player/hlsjs/loader/fragment.js +++ /dev/null @@ -1,222 +0,0 @@ -/* -AUTHOR - Trek Hopton - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -import URLToolkit from '../../url-toolkit/url-toolkit.js'; -import LevelKey from './level-key.js'; - -export const ElementaryStreamTypes = { - AUDIO: 'audio', - VIDEO: 'video' -} - -export default class Fragment { - constructor() { - this._url = null; - this._byteRange = null; - this._decryptdata = null; - - // Holds the types of data this fragment supports - this._elementaryStreams = { - [ElementaryStreamTypes.AUDIO]: false, - [ElementaryStreamTypes.VIDEO]: false - }; - - // deltaPTS tracks the change in presentation timestamp between fragments - this.deltaPTS = 0; - - this.rawProgramDateTime = null; - this.programDateTime = null; - this.title = null; - this.tagList = []; - - // TODO: Move at least baseurl to constructor. - // Currently we do a two-pass construction as use the Fragment class almost like a object for holding parsing state. - // It may make more sense to just use a POJO to keep state during the parsing phase. - // Have Fragment be the representation once we have a known state? - // Something to think on. - - // Discontinuity Counter - this.cc; - this.type; - // relurl is the portion of the URL that comes from inside the playlist. - this.relurl; - // baseurl is the URL to the playlist - this.baseurl; - // EXTINF has to be present for a m3u8 to be considered valid - this.duration; - // When this segment starts in the timeline - this.start; - // sn notates the sequence number for a segment, and if set to a string can be 'initSegment' - this.sn = 0; - - this.urlId = 0; - // level matches this fragment to a index playlist - this.level = 0; - // levelkey is the EXT-X-KEY that applies to this segment for decryption - // core difference from the private field _decryptdata is the lack of the initialized IV - // _decryptdata will set the IV for this segment based on the segment number in the fragment - this.levelkey; - - // TODO(typescript-xhrloader) - this.loader; - } - - // setByteRange converts a EXT-X-BYTERANGE attribute into a two element array - setByteRange(value, previousFrag) { - const params = value.split('@', 2); - const byteRange = []; - if (params.length === 1) { - byteRange[0] = previousFrag ? previousFrag.byteRangeEndOffset : 0; - } else { - byteRange[0] = parseInt(params[1]); - } - byteRange[1] = parseInt(params[0]) + byteRange[0]; - this._byteRange = byteRange; - } - - get url() { - if (!this._url && this.relurl) { - this._url = URLToolkit.buildAbsoluteURL(this.baseurl, this.relurl, { alwaysNormalize: true }); - } - - return this._url; - } - - set url(value) { - this._url = value; - } - - get byteRange() { - if (!this._byteRange) { - return []; - } - - return this._byteRange; - } - - /** - * @type {number} - */ - get byteRangeStartOffset() { - return this.byteRange[0]; - } - - get byteRangeEndOffset() { - return this.byteRange[1]; - } - - get decryptdata() { - if (!this.levelkey && !this._decryptdata) { - return null; - } - - if (!this._decryptdata && this.levelkey) { - let sn = this.sn; - if (typeof sn !== 'number') { - // We are fetching decryption data for a initialization segment - // If the segment was encrypted with AES-128 - // It must have an IV defined. We cannot substitute the Segment Number in. - if (this.levelkey && this.levelkey.method === 'AES-128' && !this.levelkey.iv) { - console.warn(`missing IV for initialization segment with method="${this.levelkey.method}" - compliance issue`); - } - - /* - Be converted to a Number. - 'initSegment' will become NaN. - NaN, which when converted through ToInt32() -> +0. - --- - Explicitly set sn to resulting value from implicit conversions 'initSegment' values for IV generation. - */ - sn = 0; - } - this._decryptdata = this.setDecryptDataFromLevelKey(this.levelkey, sn); - } - - return this._decryptdata; - } - - get endProgramDateTime() { - if (this.programDateTime === null) { - return null; - } - - if (!Number.isFinite(this.programDateTime)) { - return null; - } - - let duration = !Number.isFinite(this.duration) ? 0 : this.duration; - - return this.programDateTime + (duration * 1000); - } - - get encrypted() { - return !!((this.decryptdata && this.decryptdata.uri !== null) && (this.decryptdata.key === null)); - } - - /** - * @param {ElementaryStreamTypes} type - */ - addElementaryStream(type) { - this._elementaryStreams[type] = true; - } - - /** - * @param {ElementaryStreamTypes} type - */ - hasElementaryStream(type) { - return this._elementaryStreams[type] === true; - } - - /** - * Utility method for parseLevelPlaylist to create an initialization vector for a given segment - * @param {number} segmentNumber - segment number to generate IV with - * @returns {Uint8Array} - */ - createInitializationVector(segmentNumber) { - let uint8View = new Uint8Array(16); - - for (let i = 12; i < 16; i++) { - uint8View[i] = (segmentNumber >> 8 * (15 - i)) & 0xff; - } - - return uint8View; - } - - /** - * Utility method for parseLevelPlaylist to get a fragment's decryption data from the currently parsed encryption key data - * @param levelkey - a playlist's encryption info - * @param segmentNumber - the fragment's segment number - * @returns {LevelKey} - an object to be applied as a fragment's decryptdata - */ - setDecryptDataFromLevelKey(levelkey, segmentNumber) { - let decryptdata = levelkey; - - if (levelkey && levelkey.method && levelkey.uri && !levelkey.iv) { - decryptdata = new LevelKey(levelkey.baseuri, levelkey.reluri); - decryptdata.method = levelkey.method; - decryptdata.iv = this.createInitializationVector(segmentNumber); - } - - return decryptdata; - } -} diff --git a/cmd/mjpeg-player/hlsjs/loader/level-key.js b/cmd/mjpeg-player/hlsjs/loader/level-key.js deleted file mode 100644 index 8bfe4331..00000000 --- a/cmd/mjpeg-player/hlsjs/loader/level-key.js +++ /dev/null @@ -1,47 +0,0 @@ -/* -AUTHOR - Trek Hopton - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -import URLToolkit from '../../url-toolkit/url-toolkit.js'; - -export default class LevelKey { - constructor(baseURI, relativeURI) { - this._uri = null; - - this.baseuri; - this.reluri; - this.method = null; - this.key = null; - this.iv = null; - - this.baseuri = baseURI; - this.reluri = relativeURI; - } - - get uri() { - if (!this._uri && this.reluri) { - this._uri = URLToolkit.buildAbsoluteURL(this.baseuri, this.reluri, { alwaysNormalize: true }); - } - - return this._uri; - } -} diff --git a/cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js b/cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js deleted file mode 100644 index 8778dc75..00000000 --- a/cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js +++ /dev/null @@ -1,388 +0,0 @@ -/* -AUTHOR - Trek Hopton - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -import URLToolkit from '../../url-toolkit/url-toolkit.js'; -import Fragment from './fragment.js'; -import Level from './level.js'; -import LevelKey from './level-key.js'; -import AttrList from '../utils/attr-list.js'; -import { isCodecType } from '../utils/codecs.js'; - -/** - * M3U8 parser - * @module - */ - -// https://regex101.com is your friend -const MASTER_PLAYLIST_REGEX = /#EXT-X-STREAM-INF:([^\n\r]*)[\r\n]+([^\r\n]+)/g; -const MASTER_PLAYLIST_MEDIA_REGEX = /#EXT-X-MEDIA:(.*)/g; - -const LEVEL_PLAYLIST_REGEX_FAST = new RegExp([ - /#EXTINF:\s*(\d*(?:\.\d+)?)(?:,(.*)\s+)?/.source, // duration (#EXTINF:,), group 1 => duration, group 2 => title - /|(?!#)([\S+ ?]+)/.source, // segment URI, group 3 => the URI (note newline is not eaten) - /|#EXT-X-BYTERANGE:*(.+)/.source, // next segment's byterange, group 4 => range spec (x@y) - /|#EXT-X-PROGRAM-DATE-TIME:(.+)/.source, // next segment's program date/time group 5 => the datetime spec - /|#.*/.source // All other non-segment oriented tags will match with all groups empty -].join(''), 'g'); - -const LEVEL_PLAYLIST_REGEX_SLOW = /(?:(?:#(EXTM3U))|(?:#EXT-X-(PLAYLIST-TYPE):(.+))|(?:#EXT-X-(MEDIA-SEQUENCE): *(\d+))|(?:#EXT-X-(TARGETDURATION): *(\d+))|(?:#EXT-X-(KEY):(.+))|(?:#EXT-X-(START):(.+))|(?:#EXT-X-(ENDLIST))|(?:#EXT-X-(DISCONTINUITY-SEQ)UENCE:(\d+))|(?:#EXT-X-(DIS)CONTINUITY))|(?:#EXT-X-(VERSION):(\d+))|(?:#EXT-X-(MAP):(.+))|(?:(#)([^:]*):(.*))|(?:(#)(.*))(?:.*)\r?\n?/; - -const MP4_REGEX_SUFFIX = /\.(mp4|m4s|m4v|m4a)$/i; - -export default class M3U8Parser { - static findGroup(groups, mediaGroupId) { - for (let i = 0; i < groups.length; i++) { - const group = groups[i]; - if (group.id === mediaGroupId) { - return group; - } - } - } - - static convertAVC1ToAVCOTI(codec) { - let avcdata = codec.split('.'); - let result; - if (avcdata.length > 2) { - result = avcdata.shift() + '.'; - result += parseInt(avcdata.shift()).toString(16); - result += ('000' + parseInt(avcdata.shift()).toString(16)).substr(-4); - } else { - result = codec; - } - return result; - } - - static resolve(url, baseUrl) { - return URLToolkit.buildAbsoluteURL(baseUrl, url, { alwaysNormalize: true }); - } - - static parseMasterPlaylist(string, baseurl) { - // TODO(typescript-level) - let levels = []; - MASTER_PLAYLIST_REGEX.lastIndex = 0; - - // TODO(typescript-level) - function setCodecs(codecs, level) { - ['video', 'audio'].forEach((type) => { - const filtered = codecs.filter((codec) => isCodecType(codec, type)); - if (filtered.length) { - const preferred = filtered.filter((codec) => { - return codec.lastIndexOf('avc1', 0) === 0 || codec.lastIndexOf('mp4a', 0) === 0; - }); - level[`${type}Codec`] = preferred.length > 0 ? preferred[0] : filtered[0]; - - // remove from list - codecs = codecs.filter((codec) => filtered.indexOf(codec) === -1); - } - }); - - level.unknownCodecs = codecs; - } - - let result; - while ((result = MASTER_PLAYLIST_REGEX.exec(string)) != null) { - // TODO(typescript-level) - const level = {}; - - const attrs = level.attrs = new AttrList(result[1]); - level.url = M3U8Parser.resolve(result[2], baseurl); - - const resolution = attrs.decimalResolution('RESOLUTION'); - if (resolution) { - level.width = resolution.width; - level.height = resolution.height; - } - level.bitrate = attrs.decimalInteger('AVERAGE-BANDWIDTH') || attrs.decimalInteger('BANDWIDTH'); - level.name = attrs.NAME; - - setCodecs([].concat((attrs.CODECS || '').split(/[ ,]+/)), level); - - if (level.videoCodec && level.videoCodec.indexOf('avc1') !== -1) { - level.videoCodec = M3U8Parser.convertAVC1ToAVCOTI(level.videoCodec); - } - - levels.push(level); - } - return levels; - } - - static parseMasterPlaylistMedia(string, baseurl, type, audioGroups = []) { - let result; - let medias = []; - let id = 0; - MASTER_PLAYLIST_MEDIA_REGEX.lastIndex = 0; - while ((result = MASTER_PLAYLIST_MEDIA_REGEX.exec(string)) !== null) { - const attrs = new AttrList(result[1]); - if (attrs.TYPE === type) { - const media = { - id: id++, - groupId: attrs['GROUP-ID'], - name: attrs.NAME || attrs.LANGUAGE, - type, - default: (attrs.DEFAULT === 'YES'), - autoselect: (attrs.AUTOSELECT === 'YES'), - forced: (attrs.FORCED === 'YES'), - lang: attrs.LANGUAGE - }; - - if (attrs.URI) { - media.url = M3U8Parser.resolve(attrs.URI, baseurl); - } - - if (audioGroups.length) { - // If there are audio groups signalled in the manifest, let's look for a matching codec string for this track - const groupCodec = M3U8Parser.findGroup(audioGroups, media.groupId); - - // If we don't find the track signalled, lets use the first audio groups codec we have - // Acting as a best guess - media.audioCodec = groupCodec ? groupCodec.codec : audioGroups[0].codec; - } - - medias.push(media); - } - } - return medias; - } - - static parseLevelPlaylist(string, baseurl, id, type, levelUrlId) { - let currentSN = 0; - let totalduration = 0; - let level = new Level(baseurl); - let discontinuityCounter = 0; - let prevFrag = null; - let frag = new Fragment(); - let result; - let i; - let levelkey; - - let firstPdtIndex = null; - - LEVEL_PLAYLIST_REGEX_FAST.lastIndex = 0; - - while ((result = LEVEL_PLAYLIST_REGEX_FAST.exec(string)) !== null) { - const duration = result[1]; - if (duration) { // INF - frag.duration = parseFloat(duration); - // avoid sliced strings https://github.com/video-dev/hls.js/issues/939 - const title = (' ' + result[2]).slice(1); - frag.title = title || null; - frag.tagList.push(title ? ['INF', duration, title] : ['INF', duration]); - } else if (result[3]) { // url - if (Number.isFinite(frag.duration)) { - const sn = currentSN++; - frag.type = type; - frag.start = totalduration; - if (levelkey) { - frag.levelkey = levelkey; - } - frag.sn = sn; - frag.level = id; - frag.cc = discontinuityCounter; - frag.urlId = levelUrlId; - frag.baseurl = baseurl; - // avoid sliced strings https://github.com/video-dev/hls.js/issues/939 - frag.relurl = (' ' + result[3]).slice(1); - assignProgramDateTime(frag, prevFrag); - - level.fragments.push(frag); - prevFrag = frag; - totalduration += frag.duration; - - frag = new Fragment(); - } - } else if (result[4]) { // X-BYTERANGE - const data = (' ' + result[4]).slice(1); - if (prevFrag) { - frag.setByteRange(data, prevFrag); - } else { - frag.setByteRange(data); - } - } else if (result[5]) { // PROGRAM-DATE-TIME - // avoid sliced strings https://github.com/video-dev/hls.js/issues/939 - frag.rawProgramDateTime = (' ' + result[5]).slice(1); - frag.tagList.push(['PROGRAM-DATE-TIME', frag.rawProgramDateTime]); - if (firstPdtIndex === null) { - firstPdtIndex = level.fragments.length; - } - } else { - result = result[0].match(LEVEL_PLAYLIST_REGEX_SLOW); - if (!result) { - console.warn('No matches on slow regex match for level playlist!'); - continue; - } - for (i = 1; i < result.length; i++) { - if (typeof result[i] !== 'undefined') { - break; - } - } - - // avoid sliced strings https://github.com/video-dev/hls.js/issues/939 - const value1 = (' ' + result[i + 1]).slice(1); - const value2 = (' ' + result[i + 2]).slice(1); - - switch (result[i]) { - case '#': - frag.tagList.push(value2 ? [value1, value2] : [value1]); - break; - case 'PLAYLIST-TYPE': - level.type = value1.toUpperCase(); - break; - case 'MEDIA-SEQUENCE': - currentSN = level.startSN = parseInt(value1); - break; - case 'TARGETDURATION': - level.targetduration = parseFloat(value1); - break; - case 'VERSION': - level.version = parseInt(value1); - break; - case 'EXTM3U': - break; - case 'ENDLIST': - level.live = false; - break; - case 'DIS': - discontinuityCounter++; - frag.tagList.push(['DIS']); - break; - case 'DISCONTINUITY-SEQ': - discontinuityCounter = parseInt(value1); - break; - case 'KEY': { - // https://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-3.4.4 - const decryptparams = value1; - const keyAttrs = new AttrList(decryptparams); - const decryptmethod = keyAttrs.enumeratedString('METHOD'); - const decrypturi = keyAttrs.URI; - const decryptiv = keyAttrs.hexadecimalInteger('IV'); - - if (decryptmethod) { - levelkey = new LevelKey(baseurl, decrypturi); - if ((decrypturi) && (['AES-128', 'SAMPLE-AES', 'SAMPLE-AES-CENC'].indexOf(decryptmethod) >= 0)) { - levelkey.method = decryptmethod; - levelkey.key = null; - // Initialization Vector (IV) - levelkey.iv = decryptiv; - } - } - break; - } - case 'START': { - const startAttrs = new AttrList(value1); - const startTimeOffset = startAttrs.decimalFloatingPoint('TIME-OFFSET'); - // TIME-OFFSET can be 0 - if (Number.isFinite(startTimeOffset)) { - level.startTimeOffset = startTimeOffset; - } - break; - } - case 'MAP': { - const mapAttrs = new AttrList(value1); - frag.relurl = mapAttrs.URI; - if (mapAttrs.BYTERANGE) { - frag.setByteRange(mapAttrs.BYTERANGE); - } - frag.baseurl = baseurl; - frag.level = id; - frag.type = type; - frag.sn = 'initSegment'; - level.initSegment = frag; - frag = new Fragment(); - frag.rawProgramDateTime = level.initSegment.rawProgramDateTime; - break; - } - default: - console.warn(`line parsed but not handled: ${result}`); - break; - } - } - } - frag = prevFrag; - // console.log('found ' + level.fragments.length + ' fragments'); - if (frag && !frag.relurl) { - level.fragments.pop(); - totalduration -= frag.duration; - } - level.totalduration = totalduration; - level.averagetargetduration = totalduration / level.fragments.length; - level.endSN = currentSN - 1; - level.startCC = level.fragments[0] ? level.fragments[0].cc : 0; - level.endCC = discontinuityCounter; - - if (!level.initSegment && level.fragments.length) { - // this is a bit lurky but HLS really has no other way to tell us - // if the fragments are TS or MP4, except if we download them :/ - // but this is to be able to handle SIDX. - if (level.fragments.every((frag) => MP4_REGEX_SUFFIX.test(frag.relurl))) { - console.warn('MP4 fragments found but no init segment (probably no MAP, incomplete M3U8), trying to fetch SIDX'); - - frag = new Fragment(); - frag.relurl = level.fragments[0].relurl; - frag.baseurl = baseurl; - frag.level = id; - frag.type = type; - frag.sn = 'initSegment'; - - level.initSegment = frag; - level.needSidxRanges = true; - } - } - - /** - * Backfill any missing PDT values - "If the first EXT-X-PROGRAM-DATE-TIME tag in a Playlist appears after - one or more Media Segment URIs, the client SHOULD extrapolate - backward from that tag (using EXTINF durations and/or media - timestamps) to associate dates with those segments." - * We have already extrapolated forward, but all fragments up to the first instance of PDT do not have their PDTs - * computed. - */ - if (firstPdtIndex) { - backfillProgramDateTimes(level.fragments, firstPdtIndex); - } - - return level; - } -} - -function backfillProgramDateTimes(fragments, startIndex) { - let fragPrev = fragments[startIndex]; - for (let i = startIndex - 1; i >= 0; i--) { - const frag = fragments[i]; - frag.programDateTime = fragPrev.programDateTime - (frag.duration * 1000); - fragPrev = frag; - } -} - -function assignProgramDateTime(frag, prevFrag) { - if (frag.rawProgramDateTime) { - frag.programDateTime = Date.parse(frag.rawProgramDateTime); - } else if (prevFrag && prevFrag.programDateTime) { - frag.programDateTime = prevFrag.endProgramDateTime; - } - - if (!Number.isFinite(frag.programDateTime)) { - frag.programDateTime = null; - frag.rawProgramDateTime = null; - } -} diff --git a/cmd/mjpeg-player/hlsjs/loader/playlist-loader.js b/cmd/mjpeg-player/hlsjs/loader/playlist-loader.js deleted file mode 100644 index e34f236b..00000000 --- a/cmd/mjpeg-player/hlsjs/loader/playlist-loader.js +++ /dev/null @@ -1,343 +0,0 @@ -/* -AUTHOR - Trek Hopton <trek@ausocean.org> - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -import { PlaylistContextType, PlaylistLevelType } from '../types/loader.js'; -import Event from '../events.js'; -import EventHandler from '../event-handler.js'; -import M3U8Parser from './m3u8-parser.js'; - -const { performance } = window; - -class PlaylistLoader extends EventHandler { - constructor(hls) { - super(hls, - Event.MANIFEST_LOADING, - Event.LEVEL_LOADING, - Event.AUDIO_TRACK_LOADING, - Event.SUBTITLE_TRACK_LOADING); - this.hls = hls; - this.loaders = {}; - } - - /** - * @param {PlaylistContextType} type - * @returns {boolean} - */ - static canHaveQualityLevels(type) { - return (type !== PlaylistContextType.AUDIO_TRACK && - type !== PlaylistContextType.SUBTITLE_TRACK); - } - - onManifestLoading(data) { - this.load({ - url: data.url, - type: PlaylistContextType.MANIFEST, - level: 0, - id: null, - responseType: 'text' - }); - } - - // param 1 -> data: { url: string; level: number | null; id: number | null; } - onLevelLoading(data) { - this.load({ - url: data.url, - type: PlaylistContextType.LEVEL, - level: data.level, - id: data.id, - responseType: 'text' - }); - } - - onAudioTrackLoading(data) { - this.load({ - url: data.url, - type: PlaylistContextType.AUDIO_TRACK, - level: null, - id: data.id, - responseType: 'text' - }); - } - - onSubtitleTrackLoading(data) { - this.load({ - url: data.url, - type: PlaylistContextType.SUBTITLE_TRACK, - level: null, - id: data.id, - responseType: 'text' - }); - } - - static getResponseUrl(response, context) { - let url = response.url; - // responseURL not supported on some browsers (it is used to detect URL redirection) - // data-uri mode also not supported (but no need to detect redirection) - if (url === undefined || url.indexOf('data:') === 0) { - // fallback to initial URL - url = context.url; - } - return url; - } - - /** - * Map context.type to LevelType - * @param {PlaylistLoaderContext} context - * @returns {LevelType} - */ - static mapContextToLevelType(context) { - const { type } = context; - - switch (type) { - case PlaylistContextType.AUDIO_TRACK: - return PlaylistLevelType.AUDIO; - case PlaylistContextType.SUBTITLE_TRACK: - return PlaylistLevelType.SUBTITLE; - default: - return PlaylistLevelType.MAIN; - } - } - - getInternalLoader(context) { - return this.loaders[context.type]; - } - - /** - * Returns defaults or configured loader-type overloads (pLoader and loader config params) - * Default loader is XHRLoader (see utils) - * @param {PlaylistLoaderContext} context - * @returns {Loader} or other compatible configured overload - */ - createInternalLoader(context) { - const config = this.hls.config; - const PLoader = config.pLoader; - const Loader = config.loader; - // TODO(typescript-config): Verify once config is typed that InternalLoader always returns a Loader - const InternalLoader = PLoader || Loader; - - const loader = new InternalLoader(config); - - // TODO - Do we really need to assign the instance or if the dep has been lost - context.loader = loader; - this.loaders[context.type] = loader; - - return loader; - } - - resetInternalLoader(contextType) { - if (this.loaders[contextType]) { - delete this.loaders[contextType]; - } - } - - load(context) { - const config = this.hls.config; - - // Check if a loader for this context already exists - let loader = this.getInternalLoader(context); - if (loader) { - const loaderContext = loader.context; - if (loaderContext && loaderContext.url === context.url) { // same URL can't overlap - return false; - } else { - loader.abort(); - } - } - - let maxRetry; - let timeout; - let retryDelay; - let maxRetryDelay; - - // apply different configs for retries depending on - // context (manifest, level, audio/subs playlist) - switch (context.type) { - case PlaylistContextType.MANIFEST: - maxRetry = config.manifestLoadingMaxRetry; - timeout = config.manifestLoadingTimeOut; - retryDelay = config.manifestLoadingRetryDelay; - maxRetryDelay = config.manifestLoadingMaxRetryTimeout; - break; - case PlaylistContextType.LEVEL: - // Disable internal loader retry logic, since we are managing retries in Level Controller - maxRetry = 0; - maxRetryDelay = 0; - retryDelay = 0; - timeout = config.levelLoadingTimeOut; - // TODO Introduce retry settings for audio-track and subtitle-track, it should not use level retry config - break; - default: - maxRetry = config.levelLoadingMaxRetry; - timeout = config.levelLoadingTimeOut; - retryDelay = config.levelLoadingRetryDelay; - maxRetryDelay = config.levelLoadingMaxRetryTimeout; - break; - } - - loader = this.createInternalLoader(context); - - const loaderConfig = { - timeout, - maxRetry, - retryDelay, - maxRetryDelay - }; - - const loaderCallbacks = { - onSuccess: this.loadsuccess.bind(this), - onError: this.loaderror.bind(this), - onTimeout: this.loadtimeout.bind(this) - }; - - loader.load(context, loaderConfig, loaderCallbacks); - - return true; - } - - loadsuccess(response, stats, context, networkDetails = null) { - if (context.isSidxRequest) { - this._handleSidxRequest(response, context); - this._handlePlaylistLoaded(response, stats, context, networkDetails); - return; - } - - this.resetInternalLoader(context.type); - if (typeof response.data !== 'string') { - throw new Error('expected responseType of "text" for PlaylistLoader'); - } - - const string = response.data; - - stats.tload = performance.now(); - - // Validate if it is an M3U8 at all - if (string.indexOf('#EXTM3U') !== 0) { - console.error("no EXTM3U delimiter"); - return; - } - - // Check if chunk-list or master. handle empty chunk list case (first EXTINF not signaled, but TARGETDURATION present) - if (string.indexOf('#EXTINF:') > 0 || string.indexOf('#EXT-X-TARGETDURATION:') > 0) { - this._handleTrackOrLevelPlaylist(response, stats, context, networkDetails); - } else { - console.log("handling of master playlists is not implemented"); - // this._handleMasterPlaylist(response, stats, context, networkDetails); - } - - } - - loaderror(response, context, networkDetails = null) { - console.error("network error while loading", response); - } - - loadtimeout(stats, context, networkDetails = null) { - console.error("network timeout while loading", stats); - } - - _handleTrackOrLevelPlaylist(response, stats, context, networkDetails) { - const hls = this.hls; - - const { id, level, type } = context; - - const url = PlaylistLoader.getResponseUrl(response, context); - - // if the values are null, they will result in the else conditional - const levelUrlId = Number.isFinite(id) ? id : 0; - const levelId = Number.isFinite(level) ? level : levelUrlId; - - const levelType = PlaylistLoader.mapContextToLevelType(context); - const levelDetails = M3U8Parser.parseLevelPlaylist(response.data, url, levelId, levelType, levelUrlId); - - // set stats on level structure - // TODO(jstackhouse): why? mixing concerns, is it just treated as value bag? - (levelDetails).tload = stats.tload; - - // We have done our first request (Manifest-type) and receive - // not a master playlist but a chunk-list (track/level) - // We fire the manifest-loaded event anyway with the parsed level-details - // by creating a single-level structure for it. - if (type === PlaylistContextType.MANIFEST) { - const singleLevel = { - url, - details: levelDetails - }; - - hls.trigger(Event.MANIFEST_LOADED, { - levels: [singleLevel], - audioTracks: [], - url, - stats, - networkDetails - }); - } - - // save parsing time - stats.tparsed = performance.now(); - - // extend the context with the new levelDetails property - context.levelDetails = levelDetails; - - this._handlePlaylistLoaded(response, stats, context, networkDetails); - } - - _handlePlaylistLoaded(response, stats, context, networkDetails) { - const { type, level, id, levelDetails } = context; - - if (!levelDetails || !levelDetails.targetduration) { - console.error("manifest parsing error"); - return; - } - - const canHaveLevels = PlaylistLoader.canHaveQualityLevels(context.type); - if (canHaveLevels) { - this.hls.trigger(Event.LEVEL_LOADED, { - details: levelDetails, - level: level || 0, - id: id || 0, - stats, - networkDetails - }); - } else { - switch (type) { - case PlaylistContextType.AUDIO_TRACK: - this.hls.trigger(Event.AUDIO_TRACK_LOADED, { - details: levelDetails, - id, - stats, - networkDetails - }); - break; - case PlaylistContextType.SUBTITLE_TRACK: - this.hls.trigger(Event.SUBTITLE_TRACK_LOADED, { - details: levelDetails, - id, - stats, - networkDetails - }); - break; - } - } - } -} - -export default PlaylistLoader; diff --git a/cmd/mjpeg-player/hlsjs/mts-demuxer.js b/cmd/mjpeg-player/hlsjs/mts-demuxer.js deleted file mode 100644 index 5fe7c351..00000000 --- a/cmd/mjpeg-player/hlsjs/mts-demuxer.js +++ /dev/null @@ -1,369 +0,0 @@ -/* -AUTHOR - Trek Hopton <trek@ausocean.org> - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -// MTSDemuxer demultiplexes an MPEG-TS stream into its individual streams. -// While it is possible that the MPEG-TS stream may contain many streams, -// this demuxer will result in at most one stream of each type ie. video, audio, id3 metadata. -class MTSDemuxer { - constructor() { - this.init(); - } - - // init initialises MTSDemuxer's state. It can be used to reset an MTSDemuxer instance. - init() { - this.pmtParsed = false; - this._pmtId = -1; - } - - // createTrack creates and returns a track model. - /** - * @param {string} type 'audio' | 'video' | 'id3' | 'text' - * @return {object} MTSDemuxer's internal track model. - */ - static createTrack(type) { - return { - type, - pid: -1, - data: [] // This will contain Uint8Arrays representing each PES packet's payload for this track. - }; - } - - // _syncOffset scans the first 'maxScanWindow' bytes and returns an offset to the beginning of the first three MTS packets, - // or -1 if three are not found. - // A TS fragment should contain at least 3 TS packets, a PAT, a PMT, and one PID, each starting with 0x47. - static _syncOffset(data) { - const maxScanWindow = 1000; // 1000 is a reasonable number of bytes to search for the first MTS packets. - const scanWindow = Math.min(maxScanWindow, data.length - 3 * 188); - let i = 0; - while (i < scanWindow) { - if (data[i] === 0x47 && data[i + 188] === 0x47 && data[i + 2 * 188] === 0x47) { - return i; - } else { - i++; - } - } - return -1; - } - - demux(data) { - let start, len = data.length, pusi, pid, afc, offset, pes, - unknownPIDs = false; - let pmtParsed = this.pmtParsed, - videoTrack = MTSDemuxer.createTrack('video'), - audioTrack = MTSDemuxer.createTrack('audio'), - id3Track = MTSDemuxer.createTrack('id3'), - videoId, - audioId, - id3Id, - pmtId = this._pmtId, - videoData = this.videoPesData, - audioData = this.audioPesData, - id3Data = this.id3PesData, - parsePAT = this._parsePAT, - parsePMT = this._parsePMT, - parsePES = this._parsePES; - - const syncOffset = MTSDemuxer._syncOffset(data); - - // Don't parse last TS packet if incomplete. - len -= (len + syncOffset) % 188; - - // Loop through TS packets. - for (start = syncOffset; start < len; start += 188) { - if (data[start] === 0x47) { - pusi = !!(data[start + 1] & 0x40); - // pid is a 13-bit field starting at the last bit of TS[1]. - pid = ((data[start + 1] & 0x1f) << 8) + data[start + 2]; - afc = (data[start + 3] & 0x30) >> 4; - // If an adaption field is present, its length is specified by the fifth byte of the TS packet header. - if (afc > 1) { - offset = start + 5 + data[start + 4]; - // Continue if there is only adaptation field. - if (offset === (start + 188)) { - continue; - } - } else { - offset = start + 4; - } - switch (pid) { - case videoId: - if (pusi) { - if (videoData && (pes = parsePES(videoData)) && pes.pts !== undefined) { - videoTrack.data.push(pes.data); - // TODO: here pes contains data, pts, dts and len. Are all these needed? - } - videoData = { data: [], size: 0 }; - } - if (videoData) { - videoData.data.push(data.subarray(offset, start + 188)); - videoData.size += start + 188 - offset; - } - break; - case audioId: - if (pusi) { - if (audioData && (pes = parsePES(audioData)) && pes.pts !== undefined) { - audioTrack.data.push(pes.data); - } - audioData = { data: [], size: 0 }; - } - if (audioData) { - audioData.data.push(data.subarray(offset, start + 188)); - audioData.size += start + 188 - offset; - } - break; - case id3Id: - if (pusi) { - if (id3Data && (pes = parsePES(id3Data)) && pes.pts !== undefined) { - id3Track.data.push(pes.data); - } - id3Data = { data: [], size: 0 }; - } - if (id3Data) { - id3Data.data.push(data.subarray(offset, start + 188)); - id3Data.size += start + 188 - offset; - } - break; - case 0: - if (pusi) { - offset += data[offset] + 1; - } - - pmtId = this._pmtId = parsePAT(data, offset); - break; - case pmtId: - if (pusi) { - offset += data[offset] + 1; - } - - let parsedPIDs = parsePMT(data, offset); - - // Only update track id if track PID found while parsing PMT. - // This is to avoid resetting the PID to -1 in case track PID transiently disappears from the stream, - // this could happen in case of transient missing audio samples for example. - videoId = parsedPIDs.video; - if (videoId > 0) { - videoTrack.pid = videoId; - } - audioId = parsedPIDs.audio; - if (audioId > 0) { - audioTrack.pid = audioId; - } - id3Id = parsedPIDs.id3; - if (id3Id > 0) { - id3Track.pid = id3Id; - } - - if (unknownPIDs && !pmtParsed) { - // Reparse from beginning. - unknownPIDs = false; - // We set it to -188, the += 188 in the for loop will reset start to 0. - start = syncOffset - 188; - } - pmtParsed = this.pmtParsed = true; - break; - default: - unknownPIDs = true; - break; - } - } else { - console.error('TS packet did not start with 0x47'); - } - } - - // Try to parse last PES packets. - if (videoData && (pes = parsePES(videoData)) && pes.pts !== undefined) { - videoTrack.data.push(pes.data); - this.videoPesData = null; - } else { - // Either pesPkts null or PES truncated, keep it for next frag parsing. - this.videoPesData = videoData; - } - - if (audioData && (pes = parsePES(audioData)) && pes.pts !== undefined) { - audioTrack.data.push(pes.data); - this.audioPesData = null; - } else { - // Either pesPkts null or PES truncated, keep it for next frag parsing. - this.audioPesData = audioData; - } - - if (id3Data && (pes = parsePES(id3Data)) && pes.pts !== undefined) { - id3Track.data.push(pes.data); - this.id3PesData = null; - } else { - // Either pesPkts null or PES truncated, keep it for next frag parsing. - this.id3PesData = id3Data; - } - - return videoTrack; - } - - _parsePAT(data, offset) { - // Skip the PSI header and parse the first PMT entry. - return (data[offset + 10] & 0x1F) << 8 | data[offset + 11]; - // console.log('PMT PID:' + this._pmtId); - } - - _parsePMT(data, offset) { - let programInfoLength, pid, result = { audio: -1, video: -1, id3: -1 }, - sectionLength = (data[offset + 1] & 0x0f) << 8 | data[offset + 2], - tableEnd = offset + 3 + sectionLength - 4; - // To determine where the table is, we have to figure out how - // long the program info descriptors are. - programInfoLength = (data[offset + 10] & 0x0f) << 8 | data[offset + 11]; - // Advance the offset to the first entry in the mapping table. - offset += 12 + programInfoLength; - while (offset < tableEnd) { - pid = (data[offset + 1] & 0x1F) << 8 | data[offset + 2]; - switch (data[offset]) { - case 0x1c: // MJPEG - case 0xdb: // SAMPLE-AES AVC. - case 0x1b: // ITU-T Rec. H.264 and ISO/IEC 14496-10 (lower bit-rate video). - if (result.video === -1) { - result.video = pid; - } - break; - case 0xcf: // SAMPLE-AES AAC. - case 0x0f: // ISO/IEC 13818-7 ADTS AAC (MPEG-2 lower bit-rate audio). - case 0xd2: // ADPCM audio. - case 0x03: // ISO/IEC 11172-3 (MPEG-1 audio). - case 0x24: - // console.warn('HEVC stream type found, not supported for now'); - case 0x04: // or ISO/IEC 13818-3 (MPEG-2 halved sample rate audio). - if (result.audio === -1) { - result.audio = pid; - } - break; - case 0x15: // Packetized metadata (ID3) - // console.log('ID3 PID:' + pid); - if (result.id3 === -1) { - result.id3 = pid; - } - break; - default: - // console.log('unknown stream type:' + data[offset]); - break; - } - // Move to the next table entry, skip past the elementary stream descriptors, if present. - offset += ((data[offset + 3] & 0x0F) << 8 | data[offset + 4]) + 5; - } - return result; - } - - _parsePES(stream) { - let i = 0, frag, pesFlags, pesPrefix, pesLen, pesHdrLen, pesData, pesPts, pesDts, payloadStartOffset, data = stream.data; - // Safety check. - if (!stream || stream.size === 0) { - return null; - } - - // We might need up to 19 bytes to read PES header. - // If first chunk of data is less than 19 bytes, let's merge it with following ones until we get 19 bytes. - // Usually only one merge is needed (and this is rare ...). - while (data[0].length < 19 && data.length > 1) { - let newData = new Uint8Array(data[0].length + data[1].length); - newData.set(data[0]); - newData.set(data[1], data[0].length); - data[0] = newData; - data.splice(1, 1); - } - // Retrieve PTS/DTS from first fragment. - frag = data[0]; - pesPrefix = (frag[0] << 16) + (frag[1] << 8) + frag[2]; - if (pesPrefix === 1) { - pesLen = (frag[4] << 8) + frag[5]; - // If PES parsed length is not zero and greater than total received length, stop parsing. PES might be truncated. - // Minus 6 : PES header size. - if (pesLen && pesLen > stream.size - 6) { - return null; - } - - pesFlags = frag[7]; - if (pesFlags & 0xC0) { - // PES header described here : http://dvd.sourceforge.net/dvdinfo/pes-hdr.html - // As PTS / DTS is 33 bit we cannot use bitwise operator in JS, - // as Bitwise operators treat their operands as a sequence of 32 bits. - pesPts = (frag[9] & 0x0E) * 536870912 +// 1 << 29 - (frag[10] & 0xFF) * 4194304 +// 1 << 22 - (frag[11] & 0xFE) * 16384 +// 1 << 14 - (frag[12] & 0xFF) * 128 +// 1 << 7 - (frag[13] & 0xFE) / 2; - // Check if greater than 2^32 -1. - if (pesPts > 4294967295) { - // Decrement 2^33. - pesPts -= 8589934592; - } - if (pesFlags & 0x40) { - pesDts = (frag[14] & 0x0E) * 536870912 +// 1 << 29 - (frag[15] & 0xFF) * 4194304 +// 1 << 22 - (frag[16] & 0xFE) * 16384 +// 1 << 14 - (frag[17] & 0xFF) * 128 +// 1 << 7 - (frag[18] & 0xFE) / 2; - // Check if greater than 2^32 -1. - if (pesDts > 4294967295) { - // Decrement 2^33. - pesDts -= 8589934592; - } - if (pesPts - pesDts > 60 * 90000) { - // console.warn(`${Math.round((pesPts - pesDts) / 90000)}s delta between PTS and DTS, align them`); - pesPts = pesDts; - } - } else { - pesDts = pesPts; - } - } - pesHdrLen = frag[8]; - // 9 bytes : 6 bytes for PES header + 3 bytes for PES extension. - payloadStartOffset = pesHdrLen + 9; - - stream.size -= payloadStartOffset; - // Reassemble PES packet. - pesData = new Uint8Array(stream.size); - for (let j = 0, dataLen = data.length; j < dataLen; j++) { - frag = data[j]; - let len = frag.byteLength; - if (payloadStartOffset) { - if (payloadStartOffset > len) { - // Trim full frag if PES header bigger than frag. - payloadStartOffset -= len; - continue; - } else { - // Trim partial frag if PES header smaller than frag. - frag = frag.subarray(payloadStartOffset); - len -= payloadStartOffset; - payloadStartOffset = 0; - } - } - pesData.set(frag, i); - i += len; - } - if (pesLen) { - // Payload size : remove PES header + PES extension. - pesLen -= pesHdrLen + 3; - } - return { data: pesData, pts: pesPts, dts: pesDts, len: pesLen }; - } else { - return null; - } - } -} \ No newline at end of file diff --git a/cmd/mjpeg-player/hlsjs/observer.js b/cmd/mjpeg-player/hlsjs/observer.js deleted file mode 100644 index 9f300d23..00000000 --- a/cmd/mjpeg-player/hlsjs/observer.js +++ /dev/null @@ -1,38 +0,0 @@ -/* -AUTHOR - Trek Hopton <trek@ausocean.org> - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -import EventEmitter from '../eventemitter3/index.js'; - -/** - * Simple adapter sub-class of Nodejs-like EventEmitter. - */ -export class Observer extends EventEmitter { - /** - * We simply want to pass along the event-name itself - * in every call to a handler, which is the purpose of our `trigger` method - * extending the standard API. - */ - trigger(event, ...data) { - this.emit(event, event, ...data); - } -} diff --git a/cmd/mjpeg-player/hlsjs/types/loader.js b/cmd/mjpeg-player/hlsjs/types/loader.js deleted file mode 100644 index 2a23cb1b..00000000 --- a/cmd/mjpeg-player/hlsjs/types/loader.js +++ /dev/null @@ -1,42 +0,0 @@ -/* -AUTHOR - Trek Hopton <trek@ausocean.org> - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -/** - * @readonly - * @enum {string} - */ -export const PlaylistContextType = { - MANIFEST: 'manifest', - LEVEL: 'level', - AUDIO_TRACK: 'audioTrack', - SUBTITLE_TRACK: 'subtitleTrack' -} - -/** - * @enum {string} - */ -export const PlaylistLevelType = { - MAIN: 'main', - AUDIO: 'audio', - SUBTITLE: 'subtitle' -} diff --git a/cmd/mjpeg-player/hlsjs/utils/codecs.js b/cmd/mjpeg-player/hlsjs/utils/codecs.js deleted file mode 100644 index a9b345aa..00000000 --- a/cmd/mjpeg-player/hlsjs/utils/codecs.js +++ /dev/null @@ -1,98 +0,0 @@ -/* -AUTHOR - Trek Hopton <trek@ausocean.org> - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -// from http://mp4ra.org/codecs.html -const sampleEntryCodesISO = { - audio: { - 'a3ds': true, - 'ac-3': true, - 'ac-4': true, - 'alac': true, - 'alaw': true, - 'dra1': true, - 'dts+': true, - 'dts-': true, - 'dtsc': true, - 'dtse': true, - 'dtsh': true, - 'ec-3': true, - 'enca': true, - 'g719': true, - 'g726': true, - 'm4ae': true, - 'mha1': true, - 'mha2': true, - 'mhm1': true, - 'mhm2': true, - 'mlpa': true, - 'mp4a': true, - 'raw ': true, - 'Opus': true, - 'samr': true, - 'sawb': true, - 'sawp': true, - 'sevc': true, - 'sqcp': true, - 'ssmv': true, - 'twos': true, - 'ulaw': true - }, - video: { - 'avc1': true, - 'avc2': true, - 'avc3': true, - 'avc4': true, - 'avcp': true, - 'drac': true, - 'dvav': true, - 'dvhe': true, - 'encv': true, - 'hev1': true, - 'hvc1': true, - 'mjp2': true, - 'mp4v': true, - 'mvc1': true, - 'mvc2': true, - 'mvc3': true, - 'mvc4': true, - 'resv': true, - 'rv60': true, - 's263': true, - 'svc1': true, - 'svc2': true, - 'vc-1': true, - 'vp08': true, - 'vp09': true - } -}; - -function isCodecType(codec, type) { - const typeCodes = sampleEntryCodesISO[type]; - return !!typeCodes && typeCodes[codec.slice(0, 4)] === true; -} - -function isCodecSupportedInMp4(codec, type) { - return MediaSource.isTypeSupported(`${type || 'video'}/mp4;codecs="${codec}"`); -} - -export { isCodecType, isCodecSupportedInMp4 }; diff --git a/cmd/mjpeg-player/hlsjs/utils/xhr-loader.js b/cmd/mjpeg-player/hlsjs/utils/xhr-loader.js deleted file mode 100644 index 67280a87..00000000 --- a/cmd/mjpeg-player/hlsjs/utils/xhr-loader.js +++ /dev/null @@ -1,189 +0,0 @@ -/* -AUTHOR - Trek Hopton <trek@ausocean.org> - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. - - For hls.js Copyright notice and license, see LICENSE file. -*/ - -/** - * XHR based loader -*/ - -const { performance, XMLHttpRequest } = window; - -class XhrLoader { - constructor(config) { - if (config && config.xhrSetup) { - this.xhrSetup = config.xhrSetup; - } - } - - destroy() { - this.abort(); - this.loader = null; - } - - abort() { - let loader = this.loader; - if (loader && loader.readyState !== 4) { - this.stats.aborted = true; - loader.abort(); - } - - window.clearTimeout(this.requestTimeout); - this.requestTimeout = null; - window.clearTimeout(this.retryTimeout); - this.retryTimeout = null; - } - - load(context, config, callbacks) { - this.context = context; - this.config = config; - this.callbacks = callbacks; - this.stats = { trequest: performance.now(), retry: 0 }; - this.retryDelay = config.retryDelay; - this.loadInternal(); - } - - loadInternal() { - let xhr, context = this.context; - xhr = this.loader = new XMLHttpRequest(); - window.console.log("load internal xhr: " + context.url); - - let stats = this.stats; - stats.tfirst = 0; - stats.loaded = 0; - const xhrSetup = this.xhrSetup; - - try { - if (xhrSetup) { - try { - xhrSetup(xhr, context.url); - } catch (e) { - // fix xhrSetup: (xhr, url) => {xhr.setRequestHeader("Content-Language", "test");} - // not working, as xhr.setRequestHeader expects xhr.readyState === OPEN - xhr.open('GET', context.url, true); - xhrSetup(xhr, context.url); - } - } - if (!xhr.readyState) { - xhr.open('GET', context.url, true); - } - } catch (e) { - // IE11 throws an exception on xhr.open if attempting to access an HTTP resource over HTTPS - this.callbacks.onError({ code: xhr.status, text: e.message }, context, xhr); - return; - } - - if (context.rangeEnd) { - xhr.setRequestHeader('Range', 'bytes=' + context.rangeStart + '-' + (context.rangeEnd - 1)); - } - - xhr.onreadystatechange = this.readystatechange.bind(this); - xhr.onprogress = this.loadprogress.bind(this); - xhr.responseType = context.responseType; - - // setup timeout before we perform request - this.requestTimeout = window.setTimeout(this.loadtimeout.bind(this), this.config.timeout); - xhr.send(); - } - - readystatechange(event) { - let xhr = event.currentTarget, - readyState = xhr.readyState, - stats = this.stats, - context = this.context, - config = this.config; - - // don't proceed if xhr has been aborted - if (stats.aborted) { - return; - } - - // >= HEADERS_RECEIVED - if (readyState >= 2) { - // clear xhr timeout and rearm it if readyState less than 4 - window.clearTimeout(this.requestTimeout); - if (stats.tfirst === 0) { - stats.tfirst = Math.max(performance.now(), stats.trequest); - } - - if (readyState === 4) { - let status = xhr.status; - // http status between 200 to 299 are all successful - if (status >= 200 && status < 300) { - stats.tload = Math.max(stats.tfirst, performance.now()); - let data, len; - if (context.responseType === 'arraybuffer') { - data = xhr.response; - len = data.byteLength; - } else { - data = xhr.responseText; - len = data.length; - } - stats.loaded = stats.total = len; - let response = { url: xhr.responseURL, data: data }; - this.callbacks.onSuccess(response, stats, context, xhr); - } else { - // if max nb of retries reached or if http status between 400 and 499 (such error cannot be recovered, retrying is useless), return error - if (stats.retry >= config.maxRetry || (status >= 400 && status < 499)) { - console.error(`${status} while loading ${context.url}`); - this.callbacks.onError({ code: status, text: xhr.statusText }, context, xhr); - } else { - // retry - console.warn(`${status} while loading ${context.url}, retrying in ${this.retryDelay}...`); - // aborts and resets internal state - this.destroy(); - // schedule retry - this.retryTimeout = window.setTimeout(this.loadInternal.bind(this), this.retryDelay); - // set exponential backoff - this.retryDelay = Math.min(2 * this.retryDelay, config.maxRetryDelay); - stats.retry++; - } - } - } else { - // readyState >= 2 AND readyState !==4 (readyState = HEADERS_RECEIVED || LOADING) rearm timeout as xhr not finished yet - this.requestTimeout = window.setTimeout(this.loadtimeout.bind(this), config.timeout); - } - } - } - - loadtimeout() { - console.warn(`timeout while loading ${this.context.url}`); - this.callbacks.onTimeout(this.stats, this.context, null); - } - - loadprogress(event) { - let xhr = event.currentTarget, - stats = this.stats; - - stats.loaded = event.loaded; - if (event.lengthComputable) { - stats.total = event.total; - } - - let onProgress = this.callbacks.onProgress; - if (onProgress) { - // third arg is to provide on progress data - onProgress(stats, this.context, null, xhr); - } - } -} - -export default XhrLoader; diff --git a/cmd/mjpeg-player/index.html b/cmd/mjpeg-player/index.html deleted file mode 100644 index 60a40429..00000000 --- a/cmd/mjpeg-player/index.html +++ /dev/null @@ -1,59 +0,0 @@ -<!DOCTYPE html> -<!-- -AUTHOR - Trek Hopton <trek@ausocean.org> - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. ---> - -<html lang="en"> - -<head> - <meta charset="utf-8"> - <title>Mjpeg Player - - - - -
-
-
-
- -
-
-
- - - -
-
- Frame Rate: fps -
-
- -
-
-
-
-
- ©2019 Australian Ocean Laboratory Limited (AusOcean) (License) -
-
- - - \ No newline at end of file diff --git a/cmd/mjpeg-player/main.js b/cmd/mjpeg-player/main.js deleted file mode 100644 index e4a0eba0..00000000 --- a/cmd/mjpeg-player/main.js +++ /dev/null @@ -1,143 +0,0 @@ -/* -AUTHOR - Trek Hopton - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses.. -*/ - -import Hls from "./hlsjs/hls.js"; - -let started = false; -let player, viewer; - -// init gets DOM elements once the document has been loaded and adds listeners where necessary. -function init() { - document.addEventListener('DOMContentLoaded', load); - document.addEventListener('DOMContentLoaded', function () { - document.getElementById('urlBtn').addEventListener('click', load); - document.getElementById('fileInput').addEventListener('change', play); - viewer = document.getElementById('viewer'); - } - ); -} - -init(); - -// load gets the URL from the URL input element or the browser's URL bar -// and creates an Hls instance to load the content from the URL. -function load() { - let url = document.getElementById('url').value; - if (url == "") { - url = getQuery() - document.getElementById('url').value = url; - } - if (url[0] == '/') { - url = window.location.protocol + '//' + window.location.host + url; - } - if (url == "") { - return; - } - - let hls = new Hls(); - hls.loadSource(url, append); -} - -// getQuery gets everything after the question mark from the URL in the browser's URL bar. -function getQuery() { - let regex = new RegExp("\\?(.*)"); - let match = regex.exec(window.location.href); - if (match == null) { - return ''; - } else { - return decodeURIComponent(match[1].replace(/\+/g, " ")); - } -} - -// append, on the first call, starts a player worker and passes it a frame rate and the video data, -// on subsequent calls it passes the video data to the player worker. -function append(data) { - if (!started) { - player = new Worker("player.js"); - - let rate = document.getElementById('rate'); - if (rate.value && rate.value > 0) { - player.postMessage({ msg: "setFrameRate", data: rate.value }); - } - - player.onmessage = handleMessage; - - player.postMessage({ msg: "loadMtsMjpeg", data: data }, [data]); - started = true; - } else { - player.postMessage({ msg: "appendMtsMjpeg", data: data }, [data]); - } - -} - -// play will process and play the target file chosen with the file input element. -function play() { - const input = event.target.files[0]; - const reader = new FileReader(); - - reader.onload = event => { - const player = new Worker("player.js"); - - let rate = document.getElementById('rate'); - if (rate.value && rate.value > 0) { - player.postMessage({ msg: "setFrameRate", data: rate.value }); - } - - player.onmessage = handleMessage; - - switch (input.name.split('.')[1]) { - case "mjpeg": - case "mjpg": - player.postMessage({ msg: "loadMjpeg", data: event.target.result }, [event.target.result]); - break; - case "ts": - player.postMessage({ msg: "loadMtsMjpeg", data: event.target.result }, [event.target.result]); - break; - default: - console.error("unknown file format"); - break; - } - }; - reader.onerror = error => reject(error); - reader.readAsArrayBuffer(input); -} - -// handleMessage handles messgaes from the player workers, its main job is to update the display when a frame is received. -function handleMessage(e) { - switch (e.data.msg) { - case "frame": - const blob = new Blob([new Uint8Array(e.data.data)], { - type: 'video/x-motion-jpeg' - }); - const url = URL.createObjectURL(blob); - viewer.src = url; - break; - case "log": - console.log(e.data.data); - break; - case "stop": - console.log("stopped"); - break; - default: - console.error("unknown message from player"); - break; - } -} \ No newline at end of file diff --git a/cmd/mjpeg-player/player.js b/cmd/mjpeg-player/player.js deleted file mode 100644 index ea96e031..00000000 --- a/cmd/mjpeg-player/player.js +++ /dev/null @@ -1,134 +0,0 @@ -/* -AUTHOR - Trek Hopton - -LICENSE - This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean) - - It is free software: you can redistribute it and/or modify them - under the terms of the GNU General Public License as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - It is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - for more details. - - You should have received a copy of the GNU General Public License in gpl.txt. - If not, see http://www.gnu.org/licenses. -*/ - -let frameRate = 25; //Keeps track of the frame rate, default is 25fps. -self.importScripts('./lex-mjpeg.js'); -self.importScripts('./hlsjs/mts-demuxer.js'); - -const codecs = { - MJPEG: 1, - MTS_MJPEG: 2, -} - -// onmessage is called whenever the main thread sends this worker a message. -onmessage = e => { - switch (e.data.msg) { - case "setFrameRate": - frameRate = e.data.data; - break; - case "loadMjpeg": - player = new PlayerWorker(); - player.init(codecs.MJPEG); - player.append(e.data.data); - player.setFrameRate(frameRate); - player.start(); - break; - case "loadMtsMjpeg": - player = new PlayerWorker(); - player.init(codecs.MTS_MJPEG); - player.append(e.data.data); - player.start(); - break; - case "appendMtsMjpeg": - player.append(e.data.data); - break; - default: - console.error("unknown message from main thread"); - break; - } -}; - -// PlayerWorker has a FrameBuffer to hold frames and once started, passes them one at a time to the main thread. -class PlayerWorker { - init(codec) { - this.frameRate = frameRate; - this.codec = codec; - switch (codec) { - case codecs.MJPEG: - this.frameSrc = new MJPEGLexer(); - break; - case codecs.MTS_MJPEG: - this.frameSrc = new FrameBuffer(); - break; - } - } - - setFrameRate(rate) { - this.frameRate = rate; - } - - start() { - let frame = this.frameSrc.read(); - if (frame != null) { - postMessage({ msg: "frame", data: frame.buffer }, [frame.buffer]); - setTimeout(() => { this.start(); }, 1000 / this.frameRate); - } else { - postMessage({ msg: "stop" }); - } - } - - append(data) { - this.frameSrc.append(data); - } -} - -// FrameBuffer allows an array of subarrays (MJPEG frames) to be read one at a time. -class FrameBuffer { - constructor() { - this.segments = []; - this.off = { segment: 0, frame: 0 }; - this.demuxer = new MTSDemuxer(); - } - - // read returns the next single frame. - read() { - let off = this.off; - let prevOff = off; - if (this.incrementOff()) { - return this.segments[prevOff.segment][prevOff.frame]; - } else { - return null; - } - } - - append(data) { - let demuxed = this.demuxer.demux(new Uint8Array(data)); - this.segments.push(demuxed.data); - } - - incrementOff() { - if (!this.segments || !this.segments[this.off.segment]) { - return false; - } - if (this.off.frame + 1 >= this.segments[this.off.segment].length) { - if (this.off.segment + 1 >= this.segments.length) { - return false; - } else { - this.off.segment++; - this.off.frame = 0; - return true; - } - } else { - this.off.frame++; - return true; - } - } -} \ No newline at end of file diff --git a/cmd/mjpeg-player/url-toolkit/url-toolkit.js b/cmd/mjpeg-player/url-toolkit/url-toolkit.js deleted file mode 100644 index 75c7f78a..00000000 --- a/cmd/mjpeg-player/url-toolkit/url-toolkit.js +++ /dev/null @@ -1,149 +0,0 @@ -// see https://tools.ietf.org/html/rfc1808 - -var URL_REGEX = /^((?:[a-zA-Z0-9+\-.]+:)?)(\/\/[^\/?#]*)?((?:[^\/\?#]*\/)*.*?)??(;.*?)?(\?.*?)?(#.*?)?$/; -var FIRST_SEGMENT_REGEX = /^([^\/?#]*)(.*)$/; -var SLASH_DOT_REGEX = /(?:\/|^)\.(?=\/)/g; -var SLASH_DOT_DOT_REGEX = /(?:\/|^)\.\.\/(?!\.\.\/).*?(?=\/)/g; - -var URLToolkit = { // jshint ignore:line - // If opts.alwaysNormalize is true then the path will always be normalized even when it starts with / or // - // E.g - // With opts.alwaysNormalize = false (default, spec compliant) - // http://a.com/b/cd + /e/f/../g => http://a.com/e/f/../g - // With opts.alwaysNormalize = true (not spec compliant) - // http://a.com/b/cd + /e/f/../g => http://a.com/e/g - buildAbsoluteURL: function (baseURL, relativeURL, opts) { - opts = opts || {}; - // remove any remaining space and CRLF - baseURL = baseURL.trim(); - relativeURL = relativeURL.trim(); - if (!relativeURL) { - // 2a) If the embedded URL is entirely empty, it inherits the - // entire base URL (i.e., is set equal to the base URL) - // and we are done. - if (!opts.alwaysNormalize) { - return baseURL; - } - var basePartsForNormalise = URLToolkit.parseURL(baseURL); - if (!basePartsForNormalise) { - throw new Error('Error trying to parse base URL.'); - } - basePartsForNormalise.path = URLToolkit.normalizePath(basePartsForNormalise.path); - return URLToolkit.buildURLFromParts(basePartsForNormalise); - } - var relativeParts = URLToolkit.parseURL(relativeURL); - if (!relativeParts) { - throw new Error('Error trying to parse relative URL.'); - } - if (relativeParts.scheme) { - // 2b) If the embedded URL starts with a scheme name, it is - // interpreted as an absolute URL and we are done. - if (!opts.alwaysNormalize) { - return relativeURL; - } - relativeParts.path = URLToolkit.normalizePath(relativeParts.path); - return URLToolkit.buildURLFromParts(relativeParts); - } - var baseParts = URLToolkit.parseURL(baseURL); - if (!baseParts) { - throw new Error('Error trying to parse base URL.'); - } - if (!baseParts.netLoc && baseParts.path && baseParts.path[0] !== '/') { - // If netLoc missing and path doesn't start with '/', assume everthing before the first '/' is the netLoc - // This causes 'example.com/a' to be handled as '//example.com/a' instead of '/example.com/a' - var pathParts = FIRST_SEGMENT_REGEX.exec(baseParts.path); - baseParts.netLoc = pathParts[1]; - baseParts.path = pathParts[2]; - } - if (baseParts.netLoc && !baseParts.path) { - baseParts.path = '/'; - } - var builtParts = { - // 2c) Otherwise, the embedded URL inherits the scheme of - // the base URL. - scheme: baseParts.scheme, - netLoc: relativeParts.netLoc, - path: null, - params: relativeParts.params, - query: relativeParts.query, - fragment: relativeParts.fragment - }; - if (!relativeParts.netLoc) { - // 3) If the embedded URL's is non-empty, we skip to - // Step 7. Otherwise, the embedded URL inherits the - // (if any) of the base URL. - builtParts.netLoc = baseParts.netLoc; - // 4) If the embedded URL path is preceded by a slash "/", the - // path is not relative and we skip to Step 7. - if (relativeParts.path[0] !== '/') { - if (!relativeParts.path) { - // 5) If the embedded URL path is empty (and not preceded by a - // slash), then the embedded URL inherits the base URL path - builtParts.path = baseParts.path; - // 5a) if the embedded URL's is non-empty, we skip to - // step 7; otherwise, it inherits the of the base - // URL (if any) and - if (!relativeParts.params) { - builtParts.params = baseParts.params; - // 5b) if the embedded URL's is non-empty, we skip to - // step 7; otherwise, it inherits the of the base - // URL (if any) and we skip to step 7. - if (!relativeParts.query) { - builtParts.query = baseParts.query; - } - } - } else { - // 6) The last segment of the base URL's path (anything - // following the rightmost slash "/", or the entire path if no - // slash is present) is removed and the embedded URL's path is - // appended in its place. - var baseURLPath = baseParts.path; - var newPath = baseURLPath.substring(0, baseURLPath.lastIndexOf('/') + 1) + relativeParts.path; - builtParts.path = URLToolkit.normalizePath(newPath); - } - } - } - if (builtParts.path === null) { - builtParts.path = opts.alwaysNormalize ? URLToolkit.normalizePath(relativeParts.path) : relativeParts.path; - } - return URLToolkit.buildURLFromParts(builtParts); - }, - parseURL: function (url) { - var parts = URL_REGEX.exec(url); - if (!parts) { - return null; - } - return { - scheme: parts[1] || '', - netLoc: parts[2] || '', - path: parts[3] || '', - params: parts[4] || '', - query: parts[5] || '', - fragment: parts[6] || '' - }; - }, - normalizePath: function (path) { - // The following operations are - // then applied, in order, to the new path: - // 6a) All occurrences of "./", where "." is a complete path - // segment, are removed. - // 6b) If the path ends with "." as a complete path segment, - // that "." is removed. - path = path.split('').reverse().join('').replace(SLASH_DOT_REGEX, ''); - // 6c) All occurrences of "/../", where is a - // complete path segment not equal to "..", are removed. - // Removal of these path segments is performed iteratively, - // removing the leftmost matching pattern on each iteration, - // until no matching pattern remains. - // 6d) If the path ends with "/..", where is a - // complete path segment not equal to "..", that - // "/.." is removed. - while (path.length !== (path = path.replace(SLASH_DOT_DOT_REGEX, '')).length) { } // jshint ignore:line - return path.split('').reverse().join(''); - }, - buildURLFromParts: function (parts) { - return parts.scheme + parts.netLoc + parts.path + parts.params + parts.query + parts.fragment; - } -}; - -export default URLToolkit; From 31dc50a4a2687bfc7b1e1fb76eafefc4a9daf9dd Mon Sep 17 00:00:00 2001 From: Trek H Date: Fri, 24 Jan 2020 14:18:26 +1030 Subject: [PATCH 15/15] mjpeg-player: adding back files --- cmd/mjpeg-player/eventemitter3/index.js | 336 +++++ cmd/mjpeg-player/hlsjs/config.js | 264 ++++ .../hlsjs/controller/stream-controller.js | 1337 +++++++++++++++++ cmd/mjpeg-player/hlsjs/event-handler.js | 90 ++ cmd/mjpeg-player/hlsjs/events.js | 110 ++ cmd/mjpeg-player/hlsjs/hls.js | 674 +++++++++ .../hlsjs/loader/fragment-loader.js | 116 ++ cmd/mjpeg-player/hlsjs/loader/fragment.js | 201 +++ cmd/mjpeg-player/hlsjs/loader/level-key.js | 24 + cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js | 370 +++++ .../hlsjs/loader/playlist-loader.js | 530 +++++++ cmd/mjpeg-player/hlsjs/mts-demuxer.js | 383 +++++ cmd/mjpeg-player/hlsjs/observer.js | 15 + cmd/mjpeg-player/hlsjs/types/loader.js | 132 ++ cmd/mjpeg-player/hlsjs/utils/codecs.js | 77 + cmd/mjpeg-player/hlsjs/utils/xhr-loader.js | 167 ++ cmd/mjpeg-player/index.html | 34 + cmd/mjpeg-player/main.js | 75 + cmd/mjpeg-player/player.js | 89 ++ cmd/mjpeg-player/url-toolkit/url-toolkit.js | 163 ++ 20 files changed, 5187 insertions(+) create mode 100644 cmd/mjpeg-player/eventemitter3/index.js create mode 100644 cmd/mjpeg-player/hlsjs/config.js create mode 100644 cmd/mjpeg-player/hlsjs/controller/stream-controller.js create mode 100644 cmd/mjpeg-player/hlsjs/event-handler.js create mode 100644 cmd/mjpeg-player/hlsjs/events.js create mode 100644 cmd/mjpeg-player/hlsjs/hls.js create mode 100644 cmd/mjpeg-player/hlsjs/loader/fragment-loader.js create mode 100644 cmd/mjpeg-player/hlsjs/loader/fragment.js create mode 100644 cmd/mjpeg-player/hlsjs/loader/level-key.js create mode 100644 cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js create mode 100644 cmd/mjpeg-player/hlsjs/loader/playlist-loader.js create mode 100644 cmd/mjpeg-player/hlsjs/mts-demuxer.js create mode 100644 cmd/mjpeg-player/hlsjs/observer.js create mode 100644 cmd/mjpeg-player/hlsjs/types/loader.js create mode 100644 cmd/mjpeg-player/hlsjs/utils/codecs.js create mode 100644 cmd/mjpeg-player/hlsjs/utils/xhr-loader.js create mode 100644 cmd/mjpeg-player/index.html create mode 100644 cmd/mjpeg-player/main.js create mode 100644 cmd/mjpeg-player/player.js create mode 100644 cmd/mjpeg-player/url-toolkit/url-toolkit.js diff --git a/cmd/mjpeg-player/eventemitter3/index.js b/cmd/mjpeg-player/eventemitter3/index.js new file mode 100644 index 00000000..6ea485c4 --- /dev/null +++ b/cmd/mjpeg-player/eventemitter3/index.js @@ -0,0 +1,336 @@ +'use strict'; + +var has = Object.prototype.hasOwnProperty + , prefix = '~'; + +/** + * Constructor to create a storage for our `EE` objects. + * An `Events` instance is a plain object whose properties are event names. + * + * @constructor + * @private + */ +function Events() {} + +// +// We try to not inherit from `Object.prototype`. In some engines creating an +// instance in this way is faster than calling `Object.create(null)` directly. +// If `Object.create(null)` is not supported we prefix the event names with a +// character to make sure that the built-in object properties are not +// overridden or used as an attack vector. +// +if (Object.create) { + Events.prototype = Object.create(null); + + // + // This hack is needed because the `__proto__` property is still inherited in + // some old browsers like Android 4, iPhone 5.1, Opera 11 and Safari 5. + // + if (!new Events().__proto__) prefix = false; +} + +/** + * Representation of a single event listener. + * + * @param {Function} fn The listener function. + * @param {*} context The context to invoke the listener with. + * @param {Boolean} [once=false] Specify if the listener is a one-time listener. + * @constructor + * @private + */ +function EE(fn, context, once) { + this.fn = fn; + this.context = context; + this.once = once || false; +} + +/** + * Add a listener for a given event. + * + * @param {EventEmitter} emitter Reference to the `EventEmitter` instance. + * @param {(String|Symbol)} event The event name. + * @param {Function} fn The listener function. + * @param {*} context The context to invoke the listener with. + * @param {Boolean} once Specify if the listener is a one-time listener. + * @returns {EventEmitter} + * @private + */ +function addListener(emitter, event, fn, context, once) { + if (typeof fn !== 'function') { + throw new TypeError('The listener must be a function'); + } + + var listener = new EE(fn, context || emitter, once) + , evt = prefix ? prefix + event : event; + + if (!emitter._events[evt]) emitter._events[evt] = listener, emitter._eventsCount++; + else if (!emitter._events[evt].fn) emitter._events[evt].push(listener); + else emitter._events[evt] = [emitter._events[evt], listener]; + + return emitter; +} + +/** + * Clear event by name. + * + * @param {EventEmitter} emitter Reference to the `EventEmitter` instance. + * @param {(String|Symbol)} evt The Event name. + * @private + */ +function clearEvent(emitter, evt) { + if (--emitter._eventsCount === 0) emitter._events = new Events(); + else delete emitter._events[evt]; +} + +/** + * Minimal `EventEmitter` interface that is molded against the Node.js + * `EventEmitter` interface. + * + * @constructor + * @public + */ +function EventEmitter() { + this._events = new Events(); + this._eventsCount = 0; +} + +/** + * Return an array listing the events for which the emitter has registered + * listeners. + * + * @returns {Array} + * @public + */ +EventEmitter.prototype.eventNames = function eventNames() { + var names = [] + , events + , name; + + if (this._eventsCount === 0) return names; + + for (name in (events = this._events)) { + if (has.call(events, name)) names.push(prefix ? name.slice(1) : name); + } + + if (Object.getOwnPropertySymbols) { + return names.concat(Object.getOwnPropertySymbols(events)); + } + + return names; +}; + +/** + * Return the listeners registered for a given event. + * + * @param {(String|Symbol)} event The event name. + * @returns {Array} The registered listeners. + * @public + */ +EventEmitter.prototype.listeners = function listeners(event) { + var evt = prefix ? prefix + event : event + , handlers = this._events[evt]; + + if (!handlers) return []; + if (handlers.fn) return [handlers.fn]; + + for (var i = 0, l = handlers.length, ee = new Array(l); i < l; i++) { + ee[i] = handlers[i].fn; + } + + return ee; +}; + +/** + * Return the number of listeners listening to a given event. + * + * @param {(String|Symbol)} event The event name. + * @returns {Number} The number of listeners. + * @public + */ +EventEmitter.prototype.listenerCount = function listenerCount(event) { + var evt = prefix ? prefix + event : event + , listeners = this._events[evt]; + + if (!listeners) return 0; + if (listeners.fn) return 1; + return listeners.length; +}; + +/** + * Calls each of the listeners registered for a given event. + * + * @param {(String|Symbol)} event The event name. + * @returns {Boolean} `true` if the event had listeners, else `false`. + * @public + */ +EventEmitter.prototype.emit = function emit(event, a1, a2, a3, a4, a5) { + var evt = prefix ? prefix + event : event; + + if (!this._events[evt]) return false; + + var listeners = this._events[evt] + , len = arguments.length + , args + , i; + + if (listeners.fn) { + if (listeners.once) this.removeListener(event, listeners.fn, undefined, true); + + switch (len) { + case 1: return listeners.fn.call(listeners.context), true; + case 2: return listeners.fn.call(listeners.context, a1), true; + case 3: return listeners.fn.call(listeners.context, a1, a2), true; + case 4: return listeners.fn.call(listeners.context, a1, a2, a3), true; + case 5: return listeners.fn.call(listeners.context, a1, a2, a3, a4), true; + case 6: return listeners.fn.call(listeners.context, a1, a2, a3, a4, a5), true; + } + + for (i = 1, args = new Array(len -1); i < len; i++) { + args[i - 1] = arguments[i]; + } + + listeners.fn.apply(listeners.context, args); + } else { + var length = listeners.length + , j; + + for (i = 0; i < length; i++) { + if (listeners[i].once) this.removeListener(event, listeners[i].fn, undefined, true); + + switch (len) { + case 1: listeners[i].fn.call(listeners[i].context); break; + case 2: listeners[i].fn.call(listeners[i].context, a1); break; + case 3: listeners[i].fn.call(listeners[i].context, a1, a2); break; + case 4: listeners[i].fn.call(listeners[i].context, a1, a2, a3); break; + default: + if (!args) for (j = 1, args = new Array(len -1); j < len; j++) { + args[j - 1] = arguments[j]; + } + + listeners[i].fn.apply(listeners[i].context, args); + } + } + } + + return true; +}; + +/** + * Add a listener for a given event. + * + * @param {(String|Symbol)} event The event name. + * @param {Function} fn The listener function. + * @param {*} [context=this] The context to invoke the listener with. + * @returns {EventEmitter} `this`. + * @public + */ +EventEmitter.prototype.on = function on(event, fn, context) { + return addListener(this, event, fn, context, false); +}; + +/** + * Add a one-time listener for a given event. + * + * @param {(String|Symbol)} event The event name. + * @param {Function} fn The listener function. + * @param {*} [context=this] The context to invoke the listener with. + * @returns {EventEmitter} `this`. + * @public + */ +EventEmitter.prototype.once = function once(event, fn, context) { + return addListener(this, event, fn, context, true); +}; + +/** + * Remove the listeners of a given event. + * + * @param {(String|Symbol)} event The event name. + * @param {Function} fn Only remove the listeners that match this function. + * @param {*} context Only remove the listeners that have this context. + * @param {Boolean} once Only remove one-time listeners. + * @returns {EventEmitter} `this`. + * @public + */ +EventEmitter.prototype.removeListener = function removeListener(event, fn, context, once) { + var evt = prefix ? prefix + event : event; + + if (!this._events[evt]) return this; + if (!fn) { + clearEvent(this, evt); + return this; + } + + var listeners = this._events[evt]; + + if (listeners.fn) { + if ( + listeners.fn === fn && + (!once || listeners.once) && + (!context || listeners.context === context) + ) { + clearEvent(this, evt); + } + } else { + for (var i = 0, events = [], length = listeners.length; i < length; i++) { + if ( + listeners[i].fn !== fn || + (once && !listeners[i].once) || + (context && listeners[i].context !== context) + ) { + events.push(listeners[i]); + } + } + + // + // Reset the array, or remove it completely if we have no more listeners. + // + if (events.length) this._events[evt] = events.length === 1 ? events[0] : events; + else clearEvent(this, evt); + } + + return this; +}; + +/** + * Remove all listeners, or those of the specified event. + * + * @param {(String|Symbol)} [event] The event name. + * @returns {EventEmitter} `this`. + * @public + */ +EventEmitter.prototype.removeAllListeners = function removeAllListeners(event) { + var evt; + + if (event) { + evt = prefix ? prefix + event : event; + if (this._events[evt]) clearEvent(this, evt); + } else { + this._events = new Events(); + this._eventsCount = 0; + } + + return this; +}; + +// +// Alias methods names because people roll like that. +// +EventEmitter.prototype.off = EventEmitter.prototype.removeListener; +EventEmitter.prototype.addListener = EventEmitter.prototype.on; + +// +// Expose the prefix. +// +EventEmitter.prefixed = prefix; + +// +// Allow `EventEmitter` to be imported as module namespace. +// +EventEmitter.EventEmitter = EventEmitter; + +// +// Expose the module. +// +if ('undefined' !== typeof module) { + module.exports = EventEmitter; +} diff --git a/cmd/mjpeg-player/hlsjs/config.js b/cmd/mjpeg-player/hlsjs/config.js new file mode 100644 index 00000000..efc8e27c --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/config.js @@ -0,0 +1,264 @@ +/** + * HLS config + */ + +import AbrController from './controller/abr-controller'; +import BufferController from './controller/buffer-controller'; +import CapLevelController from './controller/cap-level-controller'; +import FPSController from './controller/fps-controller'; +import XhrLoader from './utils/xhr-loader'; +// import FetchLoader from './utils/fetch-loader'; + +import AudioTrackController from './controller/audio-track-controller'; +import AudioStreamController from './controller/audio-stream-controller'; + +import * as Cues from './utils/cues'; +import TimelineController from './controller/timeline-controller'; +import SubtitleTrackController from './controller/subtitle-track-controller'; +import { SubtitleStreamController } from './controller/subtitle-stream-controller'; +import EMEController from './controller/eme-controller'; +import { requestMediaKeySystemAccess, MediaKeyFunc } from './utils/mediakeys-helper'; + +type ABRControllerConfig = { + abrEwmaFastLive: number, + abrEwmaSlowLive: number, + abrEwmaFastVoD: number, + abrEwmaSlowVoD: number, + abrEwmaDefaultEstimate: number, + abrBandWidthFactor: number, + abrBandWidthUpFactor: number, + abrMaxWithRealBitrate: boolean, + maxStarvationDelay: number, + maxLoadingDelay: number, +}; + +export type BufferControllerConfig = { + appendErrorMaxRetry: number, + liveDurationInfinity: boolean, + liveBackBufferLength: number, +}; + +type CapLevelControllerConfig = { + capLevelToPlayerSize: boolean +}; + +export type EMEControllerConfig = { + licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void, + emeEnabled: boolean, + widevineLicenseUrl?: string, + requestMediaKeySystemAccessFunc: MediaKeyFunc | null, +}; + +type FragmentLoaderConfig = { + fLoader: any, // TODO(typescript-loader): Once Loader is typed fill this in + + fragLoadingTimeOut: number, + fragLoadingMaxRetry: number, + fragLoadingRetryDelay: number, + fragLoadingMaxRetryTimeout: number, +}; + +type FPSControllerConfig = { + capLevelOnFPSDrop: boolean, + fpsDroppedMonitoringPeriod: number, + fpsDroppedMonitoringThreshold: number, +}; + +type LevelControllerConfig = { + startLevel?: number +}; + +type MP4RemuxerConfig = { + stretchShortVideoTrack: boolean, + maxAudioFramesDrift: number, +}; + +type PlaylistLoaderConfig = { + pLoader: any, // TODO(typescript-loader): Once Loader is typed fill this in + + manifestLoadingTimeOut: number, + manifestLoadingMaxRetry: number, + manifestLoadingRetryDelay: number, + manifestLoadingMaxRetryTimeout: number, + + levelLoadingTimeOut: number, + levelLoadingMaxRetry: number, + levelLoadingRetryDelay: number, + levelLoadingMaxRetryTimeout: number +}; + +type StreamControllerConfig = { + autoStartLoad: boolean, + startPosition: number, + defaultAudioCodec?: string, + initialLiveManifestSize: number, + maxBufferLength: number, + maxBufferSize: number, + maxBufferHole: number, + + lowBufferWatchdogPeriod: number, + highBufferWatchdogPeriod: number, + nudgeOffset: number, + nudgeMaxRetry: number, + maxFragLookUpTolerance: number, + liveSyncDurationCount: number, + liveMaxLatencyDurationCount: number, + liveSyncDuration?: number, + liveMaxLatencyDuration?: number, + maxMaxBufferLength: number, + + startFragPrefetch: boolean, +}; + +type TimelineControllerConfig = { + cueHandler: any, // TODO(typescript-cues): Type once file is done + enableCEA708Captions: boolean, + enableWebVTT: boolean, + captionsTextTrack1Label: string, + captionsTextTrack1LanguageCode: string, + captionsTextTrack2Label: string, + captionsTextTrack2LanguageCode: string, +}; + +type TSDemuxerConfig = { + forceKeyFrameOnDiscontinuity: boolean, +}; + +export type HlsConfig = + { + debug: boolean, + enableWorker: boolean, + enableSoftwareAES: boolean, + minAutoBitrate: number, + loader: any, // TODO(typescript-xhrloader): Type once XHR is done + xhrSetup?: (xhr: XMLHttpRequest, url: string) => void, + + // Alt Audio + audioStreamController?: any, // TODO(typescript-audiostreamcontroller): Type once file is done + audioTrackController?: any, // TODO(typescript-audiotrackcontroller): Type once file is done + // Subtitle + subtitleStreamController?: any, // TODO(typescript-subtitlestreamcontroller): Type once file is done + subtitleTrackController?: any, // TODO(typescript-subtitletrackcontroller): Type once file is done + timelineController?: any, // TODO(typescript-timelinecontroller): Type once file is done + // EME + emeController?: typeof EMEController, + + abrController: any, // TODO(typescript-abrcontroller): Type once file is done + bufferController: typeof BufferController, + capLevelController: any, // TODO(typescript-caplevelcontroller): Type once file is done + fpsController: any, // TODO(typescript-fpscontroller): Type once file is done + } & + ABRControllerConfig & + BufferControllerConfig & + CapLevelControllerConfig & + EMEControllerConfig & + FPSControllerConfig & + FragmentLoaderConfig & + LevelControllerConfig & + MP4RemuxerConfig & + PlaylistLoaderConfig & + StreamControllerConfig & + Partial & + TSDemuxerConfig; + +// If possible, keep hlsDefaultConfig shallow +// It is cloned whenever a new Hls instance is created, by keeping the config +// shallow the properties are cloned, and we don't end up manipulating the default +export const hlsDefaultConfig: HlsConfig = { + autoStartLoad: true, // used by stream-controller + startPosition: -1, // used by stream-controller + defaultAudioCodec: void 0, // used by stream-controller + debug: false, // used by logger + capLevelOnFPSDrop: false, // used by fps-controller + capLevelToPlayerSize: false, // used by cap-level-controller + initialLiveManifestSize: 1, // used by stream-controller + maxBufferLength: 30, // used by stream-controller + maxBufferSize: 60 * 1000 * 1000, // used by stream-controller + maxBufferHole: 0.5, // used by stream-controller + + lowBufferWatchdogPeriod: 0.5, // used by stream-controller + highBufferWatchdogPeriod: 3, // used by stream-controller + nudgeOffset: 0.1, // used by stream-controller + nudgeMaxRetry: 3, // used by stream-controller + maxFragLookUpTolerance: 0.25, // used by stream-controller + liveSyncDurationCount: 3, // used by stream-controller + liveMaxLatencyDurationCount: Infinity, // used by stream-controller + liveSyncDuration: void 0, // used by stream-controller + liveMaxLatencyDuration: void 0, // used by stream-controller + liveDurationInfinity: false, // used by buffer-controller + liveBackBufferLength: Infinity, // used by buffer-controller + maxMaxBufferLength: 600, // used by stream-controller + enableWorker: true, // used by demuxer + enableSoftwareAES: true, // used by decrypter + manifestLoadingTimeOut: 10000, // used by playlist-loader + manifestLoadingMaxRetry: 1, // used by playlist-loader + manifestLoadingRetryDelay: 1000, // used by playlist-loader + manifestLoadingMaxRetryTimeout: 64000, // used by playlist-loader + startLevel: void 0, // used by level-controller + levelLoadingTimeOut: 10000, // used by playlist-loader + levelLoadingMaxRetry: 4, // used by playlist-loader + levelLoadingRetryDelay: 1000, // used by playlist-loader + levelLoadingMaxRetryTimeout: 64000, // used by playlist-loader + fragLoadingTimeOut: 20000, // used by fragment-loader + fragLoadingMaxRetry: 6, // used by fragment-loader + fragLoadingRetryDelay: 1000, // used by fragment-loader + fragLoadingMaxRetryTimeout: 64000, // used by fragment-loader + startFragPrefetch: false, // used by stream-controller + fpsDroppedMonitoringPeriod: 5000, // used by fps-controller + fpsDroppedMonitoringThreshold: 0.2, // used by fps-controller + appendErrorMaxRetry: 3, // used by buffer-controller + loader: XhrLoader, + // loader: FetchLoader, + fLoader: void 0, // used by fragment-loader + pLoader: void 0, // used by playlist-loader + xhrSetup: void 0, // used by xhr-loader + licenseXhrSetup: void 0, // used by eme-controller + // fetchSetup: void 0, + abrController: AbrController, + bufferController: BufferController, + capLevelController: CapLevelController, + fpsController: FPSController, + stretchShortVideoTrack: false, // used by mp4-remuxer + maxAudioFramesDrift: 1, // used by mp4-remuxer + forceKeyFrameOnDiscontinuity: true, // used by ts-demuxer + abrEwmaFastLive: 3, // used by abr-controller + abrEwmaSlowLive: 9, // used by abr-controller + abrEwmaFastVoD: 3, // used by abr-controller + abrEwmaSlowVoD: 9, // used by abr-controller + abrEwmaDefaultEstimate: 5e5, // 500 kbps // used by abr-controller + abrBandWidthFactor: 0.95, // used by abr-controller + abrBandWidthUpFactor: 0.7, // used by abr-controller + abrMaxWithRealBitrate: false, // used by abr-controller + maxStarvationDelay: 4, // used by abr-controller + maxLoadingDelay: 4, // used by abr-controller + minAutoBitrate: 0, // used by hls + emeEnabled: false, // used by eme-controller + widevineLicenseUrl: void 0, // used by eme-controller + requestMediaKeySystemAccessFunc: requestMediaKeySystemAccess, // used by eme-controller + + // Dynamic Modules + ...timelineConfig(), + subtitleStreamController: (__USE_SUBTITLES__) ? SubtitleStreamController : void 0, + subtitleTrackController: (__USE_SUBTITLES__) ? SubtitleTrackController : void 0, + timelineController: (__USE_SUBTITLES__) ? TimelineController : void 0, + audioStreamController: (__USE_ALT_AUDIO__) ? AudioStreamController : void 0, + audioTrackController: (__USE_ALT_AUDIO__) ? AudioTrackController : void 0, + emeController: (__USE_EME_DRM__) ? EMEController : void 0 +}; + +function timelineConfig (): TimelineControllerConfig { + if (!__USE_SUBTITLES__) { + // intentionally doing this over returning Partial above + // this has the added nice property of still requiring the object below to completely define all props. + return {} as any; + } + return { + cueHandler: Cues, // used by timeline-controller + enableCEA708Captions: true, // used by timeline-controller + enableWebVTT: true, // used by timeline-controller + captionsTextTrack1Label: 'English', // used by timeline-controller + captionsTextTrack1LanguageCode: 'en', // used by timeline-controller + captionsTextTrack2Label: 'Spanish', // used by timeline-controller + captionsTextTrack2LanguageCode: 'es' // used by timeline-controller + }; +} diff --git a/cmd/mjpeg-player/hlsjs/controller/stream-controller.js b/cmd/mjpeg-player/hlsjs/controller/stream-controller.js new file mode 100644 index 00000000..092a55bc --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/controller/stream-controller.js @@ -0,0 +1,1337 @@ +/* + * Stream Controller +*/ + +import BinarySearch from '../utils/binary-search'; +import { BufferHelper } from '../utils/buffer-helper'; +import Demuxer from '../demux/demuxer'; +import Event from '../events'; +import { FragmentState } from './fragment-tracker'; +import { ElementaryStreamTypes } from '../loader/fragment'; +import { PlaylistLevelType } from '../types/loader'; +import * as LevelHelper from './level-helper'; +import TimeRanges from '../utils/time-ranges'; +import { ErrorDetails } from '../errors'; +import { logger } from '../utils/logger'; +import { alignStream } from '../utils/discontinuities'; +import { findFragmentByPDT, findFragmentByPTS } from './fragment-finders'; +import GapController from './gap-controller'; +import BaseStreamController, { State } from './base-stream-controller'; + +const TICK_INTERVAL = 100; // how often to tick in ms + +class StreamController extends BaseStreamController { + constructor (hls, fragmentTracker) { + super(hls, + Event.MEDIA_ATTACHED, + Event.MEDIA_DETACHING, + Event.MANIFEST_LOADING, + Event.MANIFEST_PARSED, + Event.LEVEL_LOADED, + Event.KEY_LOADED, + Event.FRAG_LOADED, + Event.FRAG_LOAD_EMERGENCY_ABORTED, + Event.FRAG_PARSING_INIT_SEGMENT, + Event.FRAG_PARSING_DATA, + Event.FRAG_PARSED, + Event.ERROR, + Event.AUDIO_TRACK_SWITCHING, + Event.AUDIO_TRACK_SWITCHED, + Event.BUFFER_CREATED, + Event.BUFFER_APPENDED, + Event.BUFFER_FLUSHED); + + this.fragmentTracker = fragmentTracker; + this.config = hls.config; + this.audioCodecSwap = false; + this._state = State.STOPPED; + this.stallReported = false; + this.gapController = null; + } + + startLoad (startPosition) { + if (this.levels) { + let lastCurrentTime = this.lastCurrentTime, hls = this.hls; + this.stopLoad(); + this.setInterval(TICK_INTERVAL); + this.level = -1; + this.fragLoadError = 0; + if (!this.startFragRequested) { + // determine load level + let startLevel = hls.startLevel; + if (startLevel === -1) { + // -1 : guess start Level by doing a bitrate test by loading first fragment of lowest quality level + startLevel = 0; + this.bitrateTest = true; + } + // set new level to playlist loader : this will trigger start level load + // hls.nextLoadLevel remains until it is set to a new value or until a new frag is successfully loaded + this.level = hls.nextLoadLevel = startLevel; + this.loadedmetadata = false; + } + // if startPosition undefined but lastCurrentTime set, set startPosition to last currentTime + if (lastCurrentTime > 0 && startPosition === -1) { + logger.log(`override startPosition with lastCurrentTime @${lastCurrentTime.toFixed(3)}`); + startPosition = lastCurrentTime; + } + this.state = State.IDLE; + this.nextLoadPosition = this.startPosition = this.lastCurrentTime = startPosition; + this.tick(); + } else { + this.forceStartLoad = true; + this.state = State.STOPPED; + } + } + + stopLoad () { + this.forceStartLoad = false; + super.stopLoad(); + } + + doTick () { + switch (this.state) { + case State.BUFFER_FLUSHING: + // in buffer flushing state, reset fragLoadError counter + this.fragLoadError = 0; + break; + case State.IDLE: + this._doTickIdle(); + break; + case State.WAITING_LEVEL: + var level = this.levels[this.level]; + // check if playlist is already loaded + if (level && level.details) { + this.state = State.IDLE; + } + + break; + case State.FRAG_LOADING_WAITING_RETRY: + var now = window.performance.now(); + var retryDate = this.retryDate; + // if current time is gt than retryDate, or if media seeking let's switch to IDLE state to retry loading + if (!retryDate || (now >= retryDate) || (this.media && this.media.seeking)) { + logger.log('mediaController: retryDate reached, switch back to IDLE state'); + this.state = State.IDLE; + } + break; + case State.ERROR: + case State.STOPPED: + case State.FRAG_LOADING: + case State.PARSING: + case State.PARSED: + case State.ENDED: + break; + default: + break; + } + // check buffer + this._checkBuffer(); + // check/update current fragment + this._checkFragmentChanged(); + } + + // Ironically the "idle" state is the on we do the most logic in it seems .... + // NOTE: Maybe we could rather schedule a check for buffer length after half of the currently + // played segment, or on pause/play/seek instead of naively checking every 100ms? + _doTickIdle () { + const hls = this.hls, + config = hls.config, + media = this.media; + + // if start level not parsed yet OR + // if video not attached AND start fragment already requested OR start frag prefetch disable + // exit loop, as we either need more info (level not parsed) or we need media to be attached to load new fragment + if (this.levelLastLoaded === undefined || ( + !media && (this.startFragRequested || !config.startFragPrefetch))) { + return; + } + + // if we have not yet loaded any fragment, start loading from start position + let pos; + if (this.loadedmetadata) { + pos = media.currentTime; + } else { + pos = this.nextLoadPosition; + } + + // determine next load level + let level = hls.nextLoadLevel, + levelInfo = this.levels[level]; + + if (!levelInfo) { + return; + } + + let levelBitrate = levelInfo.bitrate, + maxBufLen; + + // compute max Buffer Length that we could get from this load level, based on level bitrate. + if (levelBitrate) { + maxBufLen = Math.max(8 * config.maxBufferSize / levelBitrate, config.maxBufferLength); + } else { + maxBufLen = config.maxBufferLength; + } + + maxBufLen = Math.min(maxBufLen, config.maxMaxBufferLength); + + // determine next candidate fragment to be loaded, based on current position and end of buffer position + // ensure up to `config.maxMaxBufferLength` of buffer upfront + + const bufferInfo = BufferHelper.bufferInfo(this.mediaBuffer ? this.mediaBuffer : media, pos, config.maxBufferHole), + bufferLen = bufferInfo.len; + // Stay idle if we are still with buffer margins + if (bufferLen >= maxBufLen) { + return; + } + + // if buffer length is less than maxBufLen try to load a new fragment ... + logger.trace(`buffer length of ${bufferLen.toFixed(3)} is below max of ${maxBufLen.toFixed(3)}. checking for more payload ...`); + + // set next load level : this will trigger a playlist load if needed + this.level = hls.nextLoadLevel = level; + + const levelDetails = levelInfo.details; + // if level info not retrieved yet, switch state and wait for level retrieval + // if live playlist, ensure that new playlist has been refreshed to avoid loading/try to load + // a useless and outdated fragment (that might even introduce load error if it is already out of the live playlist) + if (!levelDetails || (levelDetails.live && this.levelLastLoaded !== level)) { + this.state = State.WAITING_LEVEL; + return; + } + + if (this._streamEnded(bufferInfo, levelDetails)) { + const data = {}; + if (this.altAudio) { + data.type = 'video'; + } + + this.hls.trigger(Event.BUFFER_EOS, data); + this.state = State.ENDED; + return; + } + // if we have the levelDetails for the selected variant, lets continue enrichen our stream (load keys/fragments or trigger EOS, etc..) + this._fetchPayloadOrEos(pos, bufferInfo, levelDetails); + } + + _fetchPayloadOrEos (pos, bufferInfo, levelDetails) { + const fragPrevious = this.fragPrevious, + level = this.level, + fragments = levelDetails.fragments, + fragLen = fragments.length; + + // empty playlist + if (fragLen === 0) { + return; + } + + // find fragment index, contiguous with end of buffer position + let start = fragments[0].start, + end = fragments[fragLen - 1].start + fragments[fragLen - 1].duration, + bufferEnd = bufferInfo.end, + frag; + + if (levelDetails.initSegment && !levelDetails.initSegment.data) { + frag = levelDetails.initSegment; + } else { + // in case of live playlist we need to ensure that requested position is not located before playlist start + if (levelDetails.live) { + let initialLiveManifestSize = this.config.initialLiveManifestSize; + if (fragLen < initialLiveManifestSize) { + logger.warn(`Can not start playback of a level, reason: not enough fragments ${fragLen} < ${initialLiveManifestSize}`); + return; + } + + frag = this._ensureFragmentAtLivePoint(levelDetails, bufferEnd, start, end, fragPrevious, fragments, fragLen); + // if it explicitely returns null don't load any fragment and exit function now + if (frag === null) { + return; + } + } else { + // VoD playlist: if bufferEnd before start of playlist, load first fragment + if (bufferEnd < start) { + frag = fragments[0]; + } + } + } + if (!frag) { + frag = this._findFragment(start, fragPrevious, fragLen, fragments, bufferEnd, end, levelDetails); + } + + if (frag) { + if (frag.encrypted) { + logger.log(`Loading key for ${frag.sn} of [${levelDetails.startSN} ,${levelDetails.endSN}],level ${level}`); + this._loadKey(frag); + } else { + logger.log(`Loading ${frag.sn} of [${levelDetails.startSN} ,${levelDetails.endSN}],level ${level}, currentTime:${pos.toFixed(3)},bufferEnd:${bufferEnd.toFixed(3)}`); + this._loadFragment(frag); + } + } + } + + _ensureFragmentAtLivePoint (levelDetails, bufferEnd, start, end, fragPrevious, fragments, fragLen) { + const config = this.hls.config, media = this.media; + + let frag; + + // check if requested position is within seekable boundaries : + // logger.log(`start/pos/bufEnd/seeking:${start.toFixed(3)}/${pos.toFixed(3)}/${bufferEnd.toFixed(3)}/${this.media.seeking}`); + let maxLatency = config.liveMaxLatencyDuration !== undefined ? config.liveMaxLatencyDuration : config.liveMaxLatencyDurationCount * levelDetails.targetduration; + + if (bufferEnd < Math.max(start - config.maxFragLookUpTolerance, end - maxLatency)) { + let liveSyncPosition = this.liveSyncPosition = this.computeLivePosition(start, levelDetails); + logger.log(`buffer end: ${bufferEnd.toFixed(3)} is located too far from the end of live sliding playlist, reset currentTime to : ${liveSyncPosition.toFixed(3)}`); + bufferEnd = liveSyncPosition; + if (media && media.readyState && media.duration > liveSyncPosition) { + media.currentTime = liveSyncPosition; + } + + this.nextLoadPosition = liveSyncPosition; + } + + // if end of buffer greater than live edge, don't load any fragment + // this could happen if live playlist intermittently slides in the past. + // level 1 loaded [182580161,182580167] + // level 1 loaded [182580162,182580169] + // Loading 182580168 of [182580162 ,182580169],level 1 .. + // Loading 182580169 of [182580162 ,182580169],level 1 .. + // level 1 loaded [182580162,182580168] <============= here we should have bufferEnd > end. in that case break to avoid reloading 182580168 + // level 1 loaded [182580164,182580171] + // + // don't return null in case media not loaded yet (readystate === 0) + if (levelDetails.PTSKnown && bufferEnd > end && media && media.readyState) { + return null; + } + + if (this.startFragRequested && !levelDetails.PTSKnown) { + /* we are switching level on live playlist, but we don't have any PTS info for that quality level ... + try to load frag matching with next SN. + even if SN are not synchronized between playlists, loading this frag will help us + compute playlist sliding and find the right one after in case it was not the right consecutive one */ + if (fragPrevious) { + if (levelDetails.hasProgramDateTime) { + // Relies on PDT in order to switch bitrates (Support EXT-X-DISCONTINUITY without EXT-X-DISCONTINUITY-SEQUENCE) + logger.log(`live playlist, switching playlist, load frag with same PDT: ${fragPrevious.programDateTime}`); + frag = findFragmentByPDT(fragments, fragPrevious.endProgramDateTime, config.maxFragLookUpTolerance); + } else { + // Uses buffer and sequence number to calculate switch segment (required if using EXT-X-DISCONTINUITY-SEQUENCE) + const targetSN = fragPrevious.sn + 1; + if (targetSN >= levelDetails.startSN && targetSN <= levelDetails.endSN) { + const fragNext = fragments[targetSN - levelDetails.startSN]; + if (fragPrevious.cc === fragNext.cc) { + frag = fragNext; + logger.log(`live playlist, switching playlist, load frag with next SN: ${frag.sn}`); + } + } + // next frag SN not available (or not with same continuity counter) + // look for a frag sharing the same CC + if (!frag) { + frag = BinarySearch.search(fragments, function (frag) { + return fragPrevious.cc - frag.cc; + }); + if (frag) { + logger.log(`live playlist, switching playlist, load frag with same CC: ${frag.sn}`); + } + } + } + } + if (!frag) { + /* we have no idea about which fragment should be loaded. + so let's load mid fragment. it will help computing playlist sliding and find the right one + */ + frag = fragments[Math.min(fragLen - 1, Math.round(fragLen / 2))]; + logger.log(`live playlist, switching playlist, unknown, load middle frag : ${frag.sn}`); + } + } + + return frag; + } + + _findFragment (start, fragPrevious, fragLen, fragments, bufferEnd, end, levelDetails) { + const config = this.hls.config; + let frag; + + if (bufferEnd < end) { + const lookupTolerance = (bufferEnd > end - config.maxFragLookUpTolerance) ? 0 : config.maxFragLookUpTolerance; + // Remove the tolerance if it would put the bufferEnd past the actual end of stream + // Uses buffer and sequence number to calculate switch segment (required if using EXT-X-DISCONTINUITY-SEQUENCE) + frag = findFragmentByPTS(fragPrevious, fragments, bufferEnd, lookupTolerance); + } else { + // reach end of playlist + frag = fragments[fragLen - 1]; + } + if (frag) { + const curSNIdx = frag.sn - levelDetails.startSN; + const sameLevel = fragPrevious && frag.level === fragPrevious.level; + const prevFrag = fragments[curSNIdx - 1]; + const nextFrag = fragments[curSNIdx + 1]; + // logger.log('find SN matching with pos:' + bufferEnd + ':' + frag.sn); + if (fragPrevious && frag.sn === fragPrevious.sn) { + if (sameLevel && !frag.backtracked) { + if (frag.sn < levelDetails.endSN) { + let deltaPTS = fragPrevious.deltaPTS; + // if there is a significant delta between audio and video, larger than max allowed hole, + // and if previous remuxed fragment did not start with a keyframe. (fragPrevious.dropped) + // let's try to load previous fragment again to get last keyframe + // then we will reload again current fragment (that way we should be able to fill the buffer hole ...) + if (deltaPTS && deltaPTS > config.maxBufferHole && fragPrevious.dropped && curSNIdx) { + frag = prevFrag; + logger.warn('SN just loaded, with large PTS gap between audio and video, maybe frag is not starting with a keyframe ? load previous one to try to overcome this'); + } else { + frag = nextFrag; + logger.log(`SN just loaded, load next one: ${frag.sn}`, frag); + } + } else { + frag = null; + } + } else if (frag.backtracked) { + // Only backtrack a max of 1 consecutive fragment to prevent sliding back too far when little or no frags start with keyframes + if (nextFrag && nextFrag.backtracked) { + logger.warn(`Already backtracked from fragment ${nextFrag.sn}, will not backtrack to fragment ${frag.sn}. Loading fragment ${nextFrag.sn}`); + frag = nextFrag; + } else { + // If a fragment has dropped frames and it's in a same level/sequence, load the previous fragment to try and find the keyframe + // Reset the dropped count now since it won't be reset until we parse the fragment again, which prevents infinite backtracking on the same segment + logger.warn('Loaded fragment with dropped frames, backtracking 1 segment to find a keyframe'); + frag.dropped = 0; + if (prevFrag) { + frag = prevFrag; + frag.backtracked = true; + } else if (curSNIdx) { + // can't backtrack on very first fragment + frag = null; + } + } + } + } + } + return frag; + } + + _loadKey (frag) { + this.state = State.KEY_LOADING; + this.hls.trigger(Event.KEY_LOADING, { frag }); + } + + _loadFragment (frag) { + // Check if fragment is not loaded + let fragState = this.fragmentTracker.getState(frag); + + this.fragCurrent = frag; + if (frag.sn !== 'initSegment') { + this.startFragRequested = true; + } + // Don't update nextLoadPosition for fragments which are not buffered + if (Number.isFinite(frag.sn) && !frag.bitrateTest) { + this.nextLoadPosition = frag.start + frag.duration; + } + + // Allow backtracked fragments to load + if (frag.backtracked || fragState === FragmentState.NOT_LOADED || fragState === FragmentState.PARTIAL) { + frag.autoLevel = this.hls.autoLevelEnabled; + frag.bitrateTest = this.bitrateTest; + + this.hls.trigger(Event.FRAG_LOADING, { frag }); + // lazy demuxer init, as this could take some time ... do it during frag loading + if (!this.demuxer) { + this.demuxer = new Demuxer(this.hls, 'main'); + } + + this.state = State.FRAG_LOADING; + } else if (fragState === FragmentState.APPENDING) { + // Lower the buffer size and try again + if (this._reduceMaxBufferLength(frag.duration)) { + this.fragmentTracker.removeFragment(frag); + } + } + } + + set state (nextState) { + if (this.state !== nextState) { + const previousState = this.state; + this._state = nextState; + logger.log(`main stream:${previousState}->${nextState}`); + this.hls.trigger(Event.STREAM_STATE_TRANSITION, { previousState, nextState }); + } + } + + get state () { + return this._state; + } + + getBufferedFrag (position) { + return this.fragmentTracker.getBufferedFrag(position, PlaylistLevelType.MAIN); + } + + get currentLevel () { + let media = this.media; + if (media) { + const frag = this.getBufferedFrag(media.currentTime); + if (frag) { + return frag.level; + } + } + return -1; + } + + get nextBufferedFrag () { + let media = this.media; + if (media) { + // first get end range of current fragment + return this.followingBufferedFrag(this.getBufferedFrag(media.currentTime)); + } else { + return null; + } + } + + followingBufferedFrag (frag) { + if (frag) { + // try to get range of next fragment (500ms after this range) + return this.getBufferedFrag(frag.endPTS + 0.5); + } + return null; + } + + get nextLevel () { + const frag = this.nextBufferedFrag; + if (frag) { + return frag.level; + } else { + return -1; + } + } + + _checkFragmentChanged () { + let fragPlayingCurrent, currentTime, video = this.media; + if (video && video.readyState && video.seeking === false) { + currentTime = video.currentTime; + /* if video element is in seeked state, currentTime can only increase. + (assuming that playback rate is positive ...) + As sometimes currentTime jumps back to zero after a + media decode error, check this, to avoid seeking back to + wrong position after a media decode error + */ + if (currentTime > this.lastCurrentTime) { + this.lastCurrentTime = currentTime; + } + + if (BufferHelper.isBuffered(video, currentTime)) { + fragPlayingCurrent = this.getBufferedFrag(currentTime); + } else if (BufferHelper.isBuffered(video, currentTime + 0.1)) { + /* ensure that FRAG_CHANGED event is triggered at startup, + when first video frame is displayed and playback is paused. + add a tolerance of 100ms, in case current position is not buffered, + check if current pos+100ms is buffered and use that buffer range + for FRAG_CHANGED event reporting */ + fragPlayingCurrent = this.getBufferedFrag(currentTime + 0.1); + } + if (fragPlayingCurrent) { + let fragPlaying = fragPlayingCurrent; + if (fragPlaying !== this.fragPlaying) { + this.hls.trigger(Event.FRAG_CHANGED, { frag: fragPlaying }); + const fragPlayingLevel = fragPlaying.level; + if (!this.fragPlaying || this.fragPlaying.level !== fragPlayingLevel) { + this.hls.trigger(Event.LEVEL_SWITCHED, { level: fragPlayingLevel }); + } + + this.fragPlaying = fragPlaying; + } + } + } + } + + /* + on immediate level switch : + - pause playback if playing + - cancel any pending load request + - and trigger a buffer flush + */ + immediateLevelSwitch () { + logger.log('immediateLevelSwitch'); + if (!this.immediateSwitch) { + this.immediateSwitch = true; + let media = this.media, previouslyPaused; + if (media) { + previouslyPaused = media.paused; + media.pause(); + } else { + // don't restart playback after instant level switch in case media not attached + previouslyPaused = true; + } + this.previouslyPaused = previouslyPaused; + } + let fragCurrent = this.fragCurrent; + if (fragCurrent && fragCurrent.loader) { + fragCurrent.loader.abort(); + } + + this.fragCurrent = null; + // flush everything + this.flushMainBuffer(0, Number.POSITIVE_INFINITY); + } + + /** + * on immediate level switch end, after new fragment has been buffered: + * - nudge video decoder by slightly adjusting video currentTime (if currentTime buffered) + * - resume the playback if needed + */ + immediateLevelSwitchEnd () { + const media = this.media; + if (media && media.buffered.length) { + this.immediateSwitch = false; + if (BufferHelper.isBuffered(media, media.currentTime)) { + // only nudge if currentTime is buffered + media.currentTime -= 0.0001; + } + if (!this.previouslyPaused) { + media.play(); + } + } + } + + /** + * try to switch ASAP without breaking video playback: + * in order to ensure smooth but quick level switching, + * we need to find the next flushable buffer range + * we should take into account new segment fetch time + */ + nextLevelSwitch () { + const media = this.media; + // ensure that media is defined and that metadata are available (to retrieve currentTime) + if (media && media.readyState) { + let fetchdelay, fragPlayingCurrent, nextBufferedFrag; + fragPlayingCurrent = this.getBufferedFrag(media.currentTime); + if (fragPlayingCurrent && fragPlayingCurrent.startPTS > 1) { + // flush buffer preceding current fragment (flush until current fragment start offset) + // minus 1s to avoid video freezing, that could happen if we flush keyframe of current video ... + this.flushMainBuffer(0, fragPlayingCurrent.startPTS - 1); + } + if (!media.paused) { + // add a safety delay of 1s + let nextLevelId = this.hls.nextLoadLevel, nextLevel = this.levels[nextLevelId], fragLastKbps = this.fragLastKbps; + if (fragLastKbps && this.fragCurrent) { + fetchdelay = this.fragCurrent.duration * nextLevel.bitrate / (1000 * fragLastKbps) + 1; + } else { + fetchdelay = 0; + } + } else { + fetchdelay = 0; + } + // logger.log('fetchdelay:'+fetchdelay); + // find buffer range that will be reached once new fragment will be fetched + nextBufferedFrag = this.getBufferedFrag(media.currentTime + fetchdelay); + if (nextBufferedFrag) { + // we can flush buffer range following this one without stalling playback + nextBufferedFrag = this.followingBufferedFrag(nextBufferedFrag); + if (nextBufferedFrag) { + // if we are here, we can also cancel any loading/demuxing in progress, as they are useless + let fragCurrent = this.fragCurrent; + if (fragCurrent && fragCurrent.loader) { + fragCurrent.loader.abort(); + } + + this.fragCurrent = null; + // start flush position is the start PTS of next buffered frag. + // we use frag.naxStartPTS which is max(audio startPTS, video startPTS). + // in case there is a small PTS Delta between audio and video, using maxStartPTS avoids flushing last samples from current fragment + this.flushMainBuffer(nextBufferedFrag.maxStartPTS, Number.POSITIVE_INFINITY); + } + } + } + } + + flushMainBuffer (startOffset, endOffset) { + this.state = State.BUFFER_FLUSHING; + let flushScope = { startOffset: startOffset, endOffset: endOffset }; + // if alternate audio tracks are used, only flush video, otherwise flush everything + if (this.altAudio) { + flushScope.type = 'video'; + } + + this.hls.trigger(Event.BUFFER_FLUSHING, flushScope); + } + + onMediaAttached (data) { + let media = this.media = this.mediaBuffer = data.media; + this.onvseeking = this.onMediaSeeking.bind(this); + this.onvseeked = this.onMediaSeeked.bind(this); + this.onvended = this.onMediaEnded.bind(this); + media.addEventListener('seeking', this.onvseeking); + media.addEventListener('seeked', this.onvseeked); + media.addEventListener('ended', this.onvended); + let config = this.config; + if (this.levels && config.autoStartLoad) { + this.hls.startLoad(config.startPosition); + } + + this.gapController = new GapController(config, media, this.fragmentTracker, this.hls); + } + + onMediaDetaching () { + let media = this.media; + if (media && media.ended) { + logger.log('MSE detaching and video ended, reset startPosition'); + this.startPosition = this.lastCurrentTime = 0; + } + + // reset fragment backtracked flag + let levels = this.levels; + if (levels) { + levels.forEach(level => { + if (level.details) { + level.details.fragments.forEach(fragment => { + fragment.backtracked = undefined; + }); + } + }); + } + // remove video listeners + if (media) { + media.removeEventListener('seeking', this.onvseeking); + media.removeEventListener('seeked', this.onvseeked); + media.removeEventListener('ended', this.onvended); + this.onvseeking = this.onvseeked = this.onvended = null; + } + this.media = this.mediaBuffer = null; + this.loadedmetadata = false; + this.stopLoad(); + } + + onMediaSeeked () { + const media = this.media, currentTime = media ? media.currentTime : undefined; + if (Number.isFinite(currentTime)) { + logger.log(`media seeked to ${currentTime.toFixed(3)}`); + } + + // tick to speed up FRAGMENT_PLAYING triggering + this.tick(); + } + + onManifestLoading () { + // reset buffer on manifest loading + logger.log('trigger BUFFER_RESET'); + this.hls.trigger(Event.BUFFER_RESET); + this.fragmentTracker.removeAllFragments(); + this.stalled = false; + this.startPosition = this.lastCurrentTime = 0; + } + + onManifestParsed (data) { + let aac = false, heaac = false, codec; + data.levels.forEach(level => { + // detect if we have different kind of audio codecs used amongst playlists + codec = level.audioCodec; + if (codec) { + if (codec.indexOf('mp4a.40.2') !== -1) { + aac = true; + } + + if (codec.indexOf('mp4a.40.5') !== -1) { + heaac = true; + } + } + }); + this.audioCodecSwitch = (aac && heaac); + if (this.audioCodecSwitch) { + logger.log('both AAC/HE-AAC audio found in levels; declaring level codec as HE-AAC'); + } + + this.levels = data.levels; + this.startFragRequested = false; + let config = this.config; + if (config.autoStartLoad || this.forceStartLoad) { + this.hls.startLoad(config.startPosition); + } + } + + onLevelLoaded (data) { + const newDetails = data.details; + const newLevelId = data.level; + const lastLevel = this.levels[this.levelLastLoaded]; + const curLevel = this.levels[newLevelId]; + const duration = newDetails.totalduration; + let sliding = 0; + + logger.log(`level ${newLevelId} loaded [${newDetails.startSN},${newDetails.endSN}],duration:${duration}`); + + if (newDetails.live) { + let curDetails = curLevel.details; + if (curDetails && newDetails.fragments.length > 0) { + // we already have details for that level, merge them + LevelHelper.mergeDetails(curDetails, newDetails); + sliding = newDetails.fragments[0].start; + this.liveSyncPosition = this.computeLivePosition(sliding, curDetails); + if (newDetails.PTSKnown && Number.isFinite(sliding)) { + logger.log(`live playlist sliding:${sliding.toFixed(3)}`); + } else { + logger.log('live playlist - outdated PTS, unknown sliding'); + alignStream(this.fragPrevious, lastLevel, newDetails); + } + } else { + logger.log('live playlist - first load, unknown sliding'); + newDetails.PTSKnown = false; + alignStream(this.fragPrevious, lastLevel, newDetails); + } + } else { + newDetails.PTSKnown = false; + } + // override level info + curLevel.details = newDetails; + this.levelLastLoaded = newLevelId; + this.hls.trigger(Event.LEVEL_UPDATED, { details: newDetails, level: newLevelId }); + + if (this.startFragRequested === false) { + // compute start position if set to -1. use it straight away if value is defined + if (this.startPosition === -1 || this.lastCurrentTime === -1) { + // first, check if start time offset has been set in playlist, if yes, use this value + let startTimeOffset = newDetails.startTimeOffset; + if (Number.isFinite(startTimeOffset)) { + if (startTimeOffset < 0) { + logger.log(`negative start time offset ${startTimeOffset}, count from end of last fragment`); + startTimeOffset = sliding + duration + startTimeOffset; + } + logger.log(`start time offset found in playlist, adjust startPosition to ${startTimeOffset}`); + this.startPosition = startTimeOffset; + } else { + // if live playlist, set start position to be fragment N-this.config.liveSyncDurationCount (usually 3) + if (newDetails.live) { + this.startPosition = this.computeLivePosition(sliding, newDetails); + logger.log(`configure startPosition to ${this.startPosition}`); + } else { + this.startPosition = 0; + } + } + this.lastCurrentTime = this.startPosition; + } + this.nextLoadPosition = this.startPosition; + } + // only switch batck to IDLE state if we were waiting for level to start downloading a new fragment + if (this.state === State.WAITING_LEVEL) { + this.state = State.IDLE; + } + + // trigger handler right now + this.tick(); + } + + onKeyLoaded () { + if (this.state === State.KEY_LOADING) { + this.state = State.IDLE; + this.tick(); + } + } + + onFragLoaded (data) { + const { fragCurrent, hls, levels, media } = this; + const fragLoaded = data.frag; + if (this.state === State.FRAG_LOADING && + fragCurrent && + fragLoaded.type === 'main' && + fragLoaded.level === fragCurrent.level && + fragLoaded.sn === fragCurrent.sn) { + const stats = data.stats; + const currentLevel = levels[fragCurrent.level]; + const details = currentLevel.details; + // reset frag bitrate test in any case after frag loaded event + // if this frag was loaded to perform a bitrate test AND if hls.nextLoadLevel is greater than 0 + // then this means that we should be able to load a fragment at a higher quality level + this.bitrateTest = false; + this.stats = stats; + + logger.log(`Loaded ${fragCurrent.sn} of [${details.startSN} ,${details.endSN}],level ${fragCurrent.level}`); + if (fragLoaded.bitrateTest && hls.nextLoadLevel) { + // switch back to IDLE state ... we just loaded a fragment to determine adequate start bitrate and initialize autoswitch algo + this.state = State.IDLE; + this.startFragRequested = false; + stats.tparsed = stats.tbuffered = window.performance.now(); + hls.trigger(Event.FRAG_BUFFERED, { stats: stats, frag: fragCurrent, id: 'main' }); + this.tick(); + } else if (fragLoaded.sn === 'initSegment') { + this.state = State.IDLE; + stats.tparsed = stats.tbuffered = window.performance.now(); + details.initSegment.data = data.payload; + hls.trigger(Event.FRAG_BUFFERED, { stats: stats, frag: fragCurrent, id: 'main' }); + this.tick(); + } else { + logger.log(`Parsing ${fragCurrent.sn} of [${details.startSN} ,${details.endSN}],level ${fragCurrent.level}, cc ${fragCurrent.cc}`); + this.state = State.PARSING; + this.pendingBuffering = true; + this.appended = false; + + // Bitrate test frags are not usually buffered so the fragment tracker ignores them. If Hls.js decides to buffer + // it (and therefore ends up at this line), then the fragment tracker needs to be manually informed. + if (fragLoaded.bitrateTest) { + fragLoaded.bitrateTest = false; + this.fragmentTracker.onFragLoaded({ + frag: fragLoaded + }); + } + + // time Offset is accurate if level PTS is known, or if playlist is not sliding (not live) and if media is not seeking (this is to overcome potential timestamp drifts between playlists and fragments) + const accurateTimeOffset = !(media && media.seeking) && (details.PTSKnown || !details.live); + const initSegmentData = details.initSegment ? details.initSegment.data : []; + const audioCodec = this._getAudioCodec(currentLevel); + + // transmux the MPEG-TS data to ISO-BMFF segments + const demuxer = this.demuxer = this.demuxer || new Demuxer(this.hls, 'main'); + demuxer.push( + data.payload, + initSegmentData, + audioCodec, + currentLevel.videoCodec, + fragCurrent, + details.totalduration, + accurateTimeOffset + ); + } + } + this.fragLoadError = 0; + } + + onFragParsingInitSegment (data) { + const fragCurrent = this.fragCurrent; + const fragNew = data.frag; + + if (fragCurrent && + data.id === 'main' && + fragNew.sn === fragCurrent.sn && + fragNew.level === fragCurrent.level && + this.state === State.PARSING) { + let tracks = data.tracks, trackName, track; + + // if audio track is expected to come from audio stream controller, discard any coming from main + if (tracks.audio && this.altAudio) { + delete tracks.audio; + } + + // include levelCodec in audio and video tracks + track = tracks.audio; + if (track) { + let audioCodec = this.levels[this.level].audioCodec, + ua = navigator.userAgent.toLowerCase(); + if (audioCodec && this.audioCodecSwap) { + logger.log('swapping playlist audio codec'); + if (audioCodec.indexOf('mp4a.40.5') !== -1) { + audioCodec = 'mp4a.40.2'; + } else { + audioCodec = 'mp4a.40.5'; + } + } + // in case AAC and HE-AAC audio codecs are signalled in manifest + // force HE-AAC , as it seems that most browsers prefers that way, + // except for mono streams OR on FF + // these conditions might need to be reviewed ... + if (this.audioCodecSwitch) { + // don't force HE-AAC if mono stream + if (track.metadata.channelCount !== 1 && + // don't force HE-AAC if firefox + ua.indexOf('firefox') === -1) { + audioCodec = 'mp4a.40.5'; + } + } + // HE-AAC is broken on Android, always signal audio codec as AAC even if variant manifest states otherwise + if (ua.indexOf('android') !== -1 && track.container !== 'audio/mpeg') { // Exclude mpeg audio + audioCodec = 'mp4a.40.2'; + logger.log(`Android: force audio codec to ${audioCodec}`); + } + track.levelCodec = audioCodec; + track.id = data.id; + } + track = tracks.video; + if (track) { + track.levelCodec = this.levels[this.level].videoCodec; + track.id = data.id; + } + this.hls.trigger(Event.BUFFER_CODECS, tracks); + // loop through tracks that are going to be provided to bufferController + for (trackName in tracks) { + track = tracks[trackName]; + logger.log(`main track:${trackName},container:${track.container},codecs[level/parsed]=[${track.levelCodec}/${track.codec}]`); + let initSegment = track.initSegment; + if (initSegment) { + this.appended = true; + // arm pending Buffering flag before appending a segment + this.pendingBuffering = true; + this.hls.trigger(Event.BUFFER_APPENDING, { type: trackName, data: initSegment, parent: 'main', content: 'initSegment' }); + } + } + // trigger handler right now + this.tick(); + } + } + + onFragParsingData (data) { + const fragCurrent = this.fragCurrent; + const fragNew = data.frag; + if (fragCurrent && + data.id === 'main' && + fragNew.sn === fragCurrent.sn && + fragNew.level === fragCurrent.level && + !(data.type === 'audio' && this.altAudio) && // filter out main audio if audio track is loaded through audio stream controller + this.state === State.PARSING) { + let level = this.levels[this.level], + frag = fragCurrent; + if (!Number.isFinite(data.endPTS)) { + data.endPTS = data.startPTS + fragCurrent.duration; + data.endDTS = data.startDTS + fragCurrent.duration; + } + + if (data.hasAudio === true) { + frag.addElementaryStream(ElementaryStreamTypes.AUDIO); + } + + if (data.hasVideo === true) { + frag.addElementaryStream(ElementaryStreamTypes.VIDEO); + } + + logger.log(`Parsed ${data.type},PTS:[${data.startPTS.toFixed(3)},${data.endPTS.toFixed(3)}],DTS:[${data.startDTS.toFixed(3)}/${data.endDTS.toFixed(3)}],nb:${data.nb},dropped:${data.dropped || 0}`); + + // Detect gaps in a fragment and try to fix it by finding a keyframe in the previous fragment (see _findFragments) + if (data.type === 'video') { + frag.dropped = data.dropped; + if (frag.dropped) { + if (!frag.backtracked) { + const levelDetails = level.details; + if (levelDetails && frag.sn === levelDetails.startSN) { + logger.warn('missing video frame(s) on first frag, appending with gap', frag.sn); + } else { + logger.warn('missing video frame(s), backtracking fragment', frag.sn); + // Return back to the IDLE state without appending to buffer + // Causes findFragments to backtrack a segment and find the keyframe + // Audio fragments arriving before video sets the nextLoadPosition, causing _findFragments to skip the backtracked fragment + this.fragmentTracker.removeFragment(frag); + frag.backtracked = true; + this.nextLoadPosition = data.startPTS; + this.state = State.IDLE; + this.fragPrevious = frag; + this.tick(); + return; + } + } else { + logger.warn('Already backtracked on this fragment, appending with the gap', frag.sn); + } + } else { + // Only reset the backtracked flag if we've loaded the frag without any dropped frames + frag.backtracked = false; + } + } + + let drift = LevelHelper.updateFragPTSDTS(level.details, frag, data.startPTS, data.endPTS, data.startDTS, data.endDTS), + hls = this.hls; + hls.trigger(Event.LEVEL_PTS_UPDATED, { details: level.details, level: this.level, drift: drift, type: data.type, start: data.startPTS, end: data.endPTS }); + // has remuxer dropped video frames located before first keyframe ? + [data.data1, data.data2].forEach(buffer => { + // only append in PARSING state (rationale is that an appending error could happen synchronously on first segment appending) + // in that case it is useless to append following segments + if (buffer && buffer.length && this.state === State.PARSING) { + this.appended = true; + // arm pending Buffering flag before appending a segment + this.pendingBuffering = true; + hls.trigger(Event.BUFFER_APPENDING, { type: data.type, data: buffer, parent: 'main', content: 'data' }); + } + }); + // trigger handler right now + this.tick(); + } + } + + onFragParsed (data) { + const fragCurrent = this.fragCurrent; + const fragNew = data.frag; + if (fragCurrent && + data.id === 'main' && + fragNew.sn === fragCurrent.sn && + fragNew.level === fragCurrent.level && + this.state === State.PARSING) { + this.stats.tparsed = window.performance.now(); + this.state = State.PARSED; + this._checkAppendedParsed(); + } + } + + onAudioTrackSwitching (data) { + // if any URL found on new audio track, it is an alternate audio track + let altAudio = !!data.url, + trackId = data.id; + // if we switch on main audio, ensure that main fragment scheduling is synced with media.buffered + // don't do anything if we switch to alt audio: audio stream controller is handling it. + // we will just have to change buffer scheduling on audioTrackSwitched + if (!altAudio) { + if (this.mediaBuffer !== this.media) { + logger.log('switching on main audio, use media.buffered to schedule main fragment loading'); + this.mediaBuffer = this.media; + let fragCurrent = this.fragCurrent; + // we need to refill audio buffer from main: cancel any frag loading to speed up audio switch + if (fragCurrent.loader) { + logger.log('switching to main audio track, cancel main fragment load'); + fragCurrent.loader.abort(); + } + this.fragCurrent = null; + this.fragPrevious = null; + // destroy demuxer to force init segment generation (following audio switch) + if (this.demuxer) { + this.demuxer.destroy(); + this.demuxer = null; + } + // switch to IDLE state to load new fragment + this.state = State.IDLE; + } + let hls = this.hls; + // switching to main audio, flush all audio and trigger track switched + hls.trigger(Event.BUFFER_FLUSHING, { startOffset: 0, endOffset: Number.POSITIVE_INFINITY, type: 'audio' }); + hls.trigger(Event.AUDIO_TRACK_SWITCHED, { id: trackId }); + this.altAudio = false; + } + } + + onAudioTrackSwitched (data) { + let trackId = data.id, + altAudio = !!this.hls.audioTracks[trackId].url; + if (altAudio) { + let videoBuffer = this.videoBuffer; + // if we switched on alternate audio, ensure that main fragment scheduling is synced with video sourcebuffer buffered + if (videoBuffer && this.mediaBuffer !== videoBuffer) { + logger.log('switching on alternate audio, use video.buffered to schedule main fragment loading'); + this.mediaBuffer = videoBuffer; + } + } + this.altAudio = altAudio; + this.tick(); + } + + onBufferCreated (data) { + let tracks = data.tracks, mediaTrack, name, alternate = false; + for (let type in tracks) { + let track = tracks[type]; + if (track.id === 'main') { + name = type; + mediaTrack = track; + // keep video source buffer reference + if (type === 'video') { + this.videoBuffer = tracks[type].buffer; + } + } else { + alternate = true; + } + } + if (alternate && mediaTrack) { + logger.log(`alternate track found, use ${name}.buffered to schedule main fragment loading`); + this.mediaBuffer = mediaTrack.buffer; + } else { + this.mediaBuffer = this.media; + } + } + + onBufferAppended (data) { + if (data.parent === 'main') { + const state = this.state; + if (state === State.PARSING || state === State.PARSED) { + // check if all buffers have been appended + this.pendingBuffering = (data.pending > 0); + this._checkAppendedParsed(); + } + } + } + + _checkAppendedParsed () { + // trigger handler right now + if (this.state === State.PARSED && (!this.appended || !this.pendingBuffering)) { + const frag = this.fragCurrent; + if (frag) { + const media = this.mediaBuffer ? this.mediaBuffer : this.media; + logger.log(`main buffered : ${TimeRanges.toString(media.buffered)}`); + this.fragPrevious = frag; + const stats = this.stats; + stats.tbuffered = window.performance.now(); + // we should get rid of this.fragLastKbps + this.fragLastKbps = Math.round(8 * stats.total / (stats.tbuffered - stats.tfirst)); + this.hls.trigger(Event.FRAG_BUFFERED, { stats: stats, frag: frag, id: 'main' }); + this.state = State.IDLE; + } + this.tick(); + } + } + + onError (data) { + let frag = data.frag || this.fragCurrent; + // don't handle frag error not related to main fragment + if (frag && frag.type !== 'main') { + return; + } + + // 0.5 : tolerance needed as some browsers stalls playback before reaching buffered end + let mediaBuffered = !!this.media && BufferHelper.isBuffered(this.media, this.media.currentTime) && BufferHelper.isBuffered(this.media, this.media.currentTime + 0.5); + + switch (data.details) { + case ErrorDetails.FRAG_LOAD_ERROR: + case ErrorDetails.FRAG_LOAD_TIMEOUT: + case ErrorDetails.KEY_LOAD_ERROR: + case ErrorDetails.KEY_LOAD_TIMEOUT: + if (!data.fatal) { + // keep retrying until the limit will be reached + if ((this.fragLoadError + 1) <= this.config.fragLoadingMaxRetry) { + // exponential backoff capped to config.fragLoadingMaxRetryTimeout + let delay = Math.min(Math.pow(2, this.fragLoadError) * this.config.fragLoadingRetryDelay, this.config.fragLoadingMaxRetryTimeout); + logger.warn(`mediaController: frag loading failed, retry in ${delay} ms`); + this.retryDate = window.performance.now() + delay; + // retry loading state + // if loadedmetadata is not set, it means that we are emergency switch down on first frag + // in that case, reset startFragRequested flag + if (!this.loadedmetadata) { + this.startFragRequested = false; + this.nextLoadPosition = this.startPosition; + } + this.fragLoadError++; + this.state = State.FRAG_LOADING_WAITING_RETRY; + } else { + logger.error(`mediaController: ${data.details} reaches max retry, redispatch as fatal ...`); + // switch error to fatal + data.fatal = true; + this.state = State.ERROR; + } + } + break; + case ErrorDetails.LEVEL_LOAD_ERROR: + case ErrorDetails.LEVEL_LOAD_TIMEOUT: + if (this.state !== State.ERROR) { + if (data.fatal) { + // if fatal error, stop processing + this.state = State.ERROR; + logger.warn(`streamController: ${data.details},switch to ${this.state} state ...`); + } else { + // in case of non fatal error while loading level, if level controller is not retrying to load level , switch back to IDLE + if (!data.levelRetry && this.state === State.WAITING_LEVEL) { + this.state = State.IDLE; + } + } + } + break; + case ErrorDetails.BUFFER_FULL_ERROR: + // if in appending state + if (data.parent === 'main' && (this.state === State.PARSING || this.state === State.PARSED)) { + // reduce max buf len if current position is buffered + if (mediaBuffered) { + this._reduceMaxBufferLength(this.config.maxBufferLength); + this.state = State.IDLE; + } else { + // current position is not buffered, but browser is still complaining about buffer full error + // this happens on IE/Edge, refer to https://github.com/video-dev/hls.js/pull/708 + // in that case flush the whole buffer to recover + logger.warn('buffer full error also media.currentTime is not buffered, flush everything'); + this.fragCurrent = null; + // flush everything + this.flushMainBuffer(0, Number.POSITIVE_INFINITY); + } + } + break; + default: + break; + } + } + + _reduceMaxBufferLength (minLength) { + let config = this.config; + if (config.maxMaxBufferLength >= minLength) { + // reduce max buffer length as it might be too high. we do this to avoid loop flushing ... + config.maxMaxBufferLength /= 2; + logger.warn(`main:reduce max buffer length to ${config.maxMaxBufferLength}s`); + return true; + } + return false; + } + + /** + * Checks the health of the buffer and attempts to resolve playback stalls. + * @private + */ + _checkBuffer () { + const { media } = this; + if (!media || media.readyState === 0) { + // Exit early if we don't have media or if the media hasn't bufferd anything yet (readyState 0) + return; + } + + const mediaBuffer = this.mediaBuffer ? this.mediaBuffer : media; + const buffered = mediaBuffer.buffered; + + if (!this.loadedmetadata && buffered.length) { + this.loadedmetadata = true; + this._seekToStartPos(); + } else if (this.immediateSwitch) { + this.immediateLevelSwitchEnd(); + } else { + this.gapController.poll(this.lastCurrentTime, buffered); + } + } + + onFragLoadEmergencyAborted () { + this.state = State.IDLE; + // if loadedmetadata is not set, it means that we are emergency switch down on first frag + // in that case, reset startFragRequested flag + if (!this.loadedmetadata) { + this.startFragRequested = false; + this.nextLoadPosition = this.startPosition; + } + this.tick(); + } + + onBufferFlushed () { + /* after successful buffer flushing, filter flushed fragments from bufferedFrags + use mediaBuffered instead of media (so that we will check against video.buffered ranges in case of alt audio track) + */ + const media = this.mediaBuffer ? this.mediaBuffer : this.media; + if (media) { + // filter fragments potentially evicted from buffer. this is to avoid memleak on live streams + this.fragmentTracker.detectEvictedFragments(ElementaryStreamTypes.VIDEO, media.buffered); + } + // move to IDLE once flush complete. this should trigger new fragment loading + this.state = State.IDLE; + // reset reference to frag + this.fragPrevious = null; + } + + swapAudioCodec () { + this.audioCodecSwap = !this.audioCodecSwap; + } + /** + * Seeks to the set startPosition if not equal to the mediaElement's current time. + * @private + */ + _seekToStartPos () { + const { media } = this; + const currentTime = media.currentTime; + // only adjust currentTime if different from startPosition or if startPosition not buffered + // at that stage, there should be only one buffered range, as we reach that code after first fragment has been buffered + const startPosition = media.seeking ? currentTime : this.startPosition; + // if currentTime not matching with expected startPosition or startPosition not buffered but close to first buffered + if (currentTime !== startPosition) { + // if startPosition not buffered, let's seek to buffered.start(0) + logger.log(`target start position not buffered, seek to buffered.start(0) ${startPosition} from current time ${currentTime} `); + media.currentTime = startPosition; + } + } + + _getAudioCodec (currentLevel) { + let audioCodec = this.config.defaultAudioCodec || currentLevel.audioCodec; + if (this.audioCodecSwap) { + logger.log('swapping playlist audio codec'); + if (audioCodec) { + if (audioCodec.indexOf('mp4a.40.5') !== -1) { + audioCodec = 'mp4a.40.2'; + } else { + audioCodec = 'mp4a.40.5'; + } + } + } + + return audioCodec; + } + + get liveSyncPosition () { + return this._liveSyncPosition; + } + + set liveSyncPosition (value) { + this._liveSyncPosition = value; + } +} +export default StreamController; diff --git a/cmd/mjpeg-player/hlsjs/event-handler.js b/cmd/mjpeg-player/hlsjs/event-handler.js new file mode 100644 index 00000000..06c089da --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/event-handler.js @@ -0,0 +1,90 @@ +/* +* +* All objects in the event handling chain should inherit from this class +* +*/ + +import { logger } from './utils/logger'; +import { ErrorTypes, ErrorDetails } from './errors'; +import Event from './events'; +import Hls from './hls'; + +const FORBIDDEN_EVENT_NAMES = { + 'hlsEventGeneric': true, + 'hlsHandlerDestroying': true, + 'hlsHandlerDestroyed': true +}; + +class EventHandler { + hls: Hls; + handledEvents: any[]; + useGenericHandler: boolean; + + constructor (hls: Hls, ...events: any[]) { + this.hls = hls; + this.onEvent = this.onEvent.bind(this); + this.handledEvents = events; + this.useGenericHandler = true; + + this.registerListeners(); + } + + destroy () { + this.onHandlerDestroying(); + this.unregisterListeners(); + this.onHandlerDestroyed(); + } + + protected onHandlerDestroying () {} + protected onHandlerDestroyed () {} + + isEventHandler () { + return typeof this.handledEvents === 'object' && this.handledEvents.length && typeof this.onEvent === 'function'; + } + + registerListeners () { + if (this.isEventHandler()) { + this.handledEvents.forEach(function (event) { + if (FORBIDDEN_EVENT_NAMES[event]) { + throw new Error('Forbidden event-name: ' + event); + } + + this.hls.on(event, this.onEvent); + }, this); + } + } + + unregisterListeners () { + if (this.isEventHandler()) { + this.handledEvents.forEach(function (event) { + this.hls.off(event, this.onEvent); + }, this); + } + } + + /** + * arguments: event (string), data (any) + */ + onEvent (event: string, data: any) { + this.onEventGeneric(event, data); + } + + onEventGeneric (event: string, data: any) { + let eventToFunction = function (event: string, data: any) { + let funcName = 'on' + event.replace('hls', ''); + if (typeof this[funcName] !== 'function') { + throw new Error(`Event ${event} has no generic handler in this ${this.constructor.name} class (tried ${funcName})`); + } + + return this[funcName].bind(this, data); + }; + try { + eventToFunction.call(this, event, data).call(); + } catch (err) { + logger.error(`An internal error happened while handling event ${event}. Error message: "${err.message}". Here is a stacktrace:`, err); + this.hls.trigger(Event.ERROR, { type: ErrorTypes.OTHER_ERROR, details: ErrorDetails.INTERNAL_EXCEPTION, fatal: false, event: event, err: err }); + } + } +} + +export default EventHandler; diff --git a/cmd/mjpeg-player/hlsjs/events.js b/cmd/mjpeg-player/hlsjs/events.js new file mode 100644 index 00000000..82314117 --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/events.js @@ -0,0 +1,110 @@ +/** + * @readonly + * @enum {string} + */ +const HlsEvents = { + // fired before MediaSource is attaching to media element - data: { media } + MEDIA_ATTACHING: 'hlsMediaAttaching', + // fired when MediaSource has been succesfully attached to media element - data: { } + MEDIA_ATTACHED: 'hlsMediaAttached', + // fired before detaching MediaSource from media element - data: { } + MEDIA_DETACHING: 'hlsMediaDetaching', + // fired when MediaSource has been detached from media element - data: { } + MEDIA_DETACHED: 'hlsMediaDetached', + // fired when we buffer is going to be reset - data: { } + BUFFER_RESET: 'hlsBufferReset', + // fired when we know about the codecs that we need buffers for to push into - data: {tracks : { container, codec, levelCodec, initSegment, metadata }} + BUFFER_CODECS: 'hlsBufferCodecs', + // fired when sourcebuffers have been created - data: { tracks : tracks } + BUFFER_CREATED: 'hlsBufferCreated', + // fired when we append a segment to the buffer - data: { segment: segment object } + BUFFER_APPENDING: 'hlsBufferAppending', + // fired when we are done with appending a media segment to the buffer - data : { parent : segment parent that triggered BUFFER_APPENDING, pending : nb of segments waiting for appending for this segment parent} + BUFFER_APPENDED: 'hlsBufferAppended', + // fired when the stream is finished and we want to notify the media buffer that there will be no more data - data: { } + BUFFER_EOS: 'hlsBufferEos', + // fired when the media buffer should be flushed - data { startOffset, endOffset } + BUFFER_FLUSHING: 'hlsBufferFlushing', + // fired when the media buffer has been flushed - data: { } + BUFFER_FLUSHED: 'hlsBufferFlushed', + // fired to signal that a manifest loading starts - data: { url : manifestURL} + MANIFEST_LOADING: 'hlsManifestLoading', + // fired after manifest has been loaded - data: { levels : [available quality levels], audioTracks : [ available audio tracks], url : manifestURL, stats : { trequest, tfirst, tload, mtime}} + MANIFEST_LOADED: 'hlsManifestLoaded', + // fired after manifest has been parsed - data: { levels : [available quality levels], firstLevel : index of first quality level appearing in Manifest} + MANIFEST_PARSED: 'hlsManifestParsed', + // fired when a level switch is requested - data: { level : id of new level } + LEVEL_SWITCHING: 'hlsLevelSwitching', + // fired when a level switch is effective - data: { level : id of new level } + LEVEL_SWITCHED: 'hlsLevelSwitched', + // fired when a level playlist loading starts - data: { url : level URL, level : id of level being loaded} + LEVEL_LOADING: 'hlsLevelLoading', + // fired when a level playlist loading finishes - data: { details : levelDetails object, level : id of loaded level, stats : { trequest, tfirst, tload, mtime} } + LEVEL_LOADED: 'hlsLevelLoaded', + // fired when a level's details have been updated based on previous details, after it has been loaded - data: { details : levelDetails object, level : id of updated level } + LEVEL_UPDATED: 'hlsLevelUpdated', + // fired when a level's PTS information has been updated after parsing a fragment - data: { details : levelDetails object, level : id of updated level, drift: PTS drift observed when parsing last fragment } + LEVEL_PTS_UPDATED: 'hlsLevelPtsUpdated', + // fired to notify that audio track lists has been updated - data: { audioTracks : audioTracks } + AUDIO_TRACKS_UPDATED: 'hlsAudioTracksUpdated', + // fired when an audio track switching is requested - data: { id : audio track id } + AUDIO_TRACK_SWITCHING: 'hlsAudioTrackSwitching', + // fired when an audio track switch actually occurs - data: { id : audio track id } + AUDIO_TRACK_SWITCHED: 'hlsAudioTrackSwitched', + // fired when an audio track loading starts - data: { url : audio track URL, id : audio track id } + AUDIO_TRACK_LOADING: 'hlsAudioTrackLoading', + // fired when an audio track loading finishes - data: { details : levelDetails object, id : audio track id, stats : { trequest, tfirst, tload, mtime } } + AUDIO_TRACK_LOADED: 'hlsAudioTrackLoaded', + // fired to notify that subtitle track lists has been updated - data: { subtitleTracks : subtitleTracks } + SUBTITLE_TRACKS_UPDATED: 'hlsSubtitleTracksUpdated', + // fired when an subtitle track switch occurs - data: { id : subtitle track id } + SUBTITLE_TRACK_SWITCH: 'hlsSubtitleTrackSwitch', + // fired when a subtitle track loading starts - data: { url : subtitle track URL, id : subtitle track id } + SUBTITLE_TRACK_LOADING: 'hlsSubtitleTrackLoading', + // fired when a subtitle track loading finishes - data: { details : levelDetails object, id : subtitle track id, stats : { trequest, tfirst, tload, mtime } } + SUBTITLE_TRACK_LOADED: 'hlsSubtitleTrackLoaded', + // fired when a subtitle fragment has been processed - data: { success : boolean, frag : the processed frag } + SUBTITLE_FRAG_PROCESSED: 'hlsSubtitleFragProcessed', + // fired when the first timestamp is found - data: { id : demuxer id, initPTS: initPTS, frag : fragment object } + INIT_PTS_FOUND: 'hlsInitPtsFound', + // fired when a fragment loading starts - data: { frag : fragment object } + FRAG_LOADING: 'hlsFragLoading', + // fired when a fragment loading is progressing - data: { frag : fragment object, { trequest, tfirst, loaded } } + FRAG_LOAD_PROGRESS: 'hlsFragLoadProgress', + // Identifier for fragment load aborting for emergency switch down - data: { frag : fragment object } + FRAG_LOAD_EMERGENCY_ABORTED: 'hlsFragLoadEmergencyAborted', + // fired when a fragment loading is completed - data: { frag : fragment object, payload : fragment payload, stats : { trequest, tfirst, tload, length } } + FRAG_LOADED: 'hlsFragLoaded', + // fired when a fragment has finished decrypting - data: { id : demuxer id, frag: fragment object, payload : fragment payload, stats : { tstart, tdecrypt } } + FRAG_DECRYPTED: 'hlsFragDecrypted', + // fired when Init Segment has been extracted from fragment - data: { id : demuxer id, frag: fragment object, moov : moov MP4 box, codecs : codecs found while parsing fragment } + FRAG_PARSING_INIT_SEGMENT: 'hlsFragParsingInitSegment', + // fired when parsing sei text is completed - data: { id : demuxer id, frag: fragment object, samples : [ sei samples pes ] } + FRAG_PARSING_USERDATA: 'hlsFragParsingUserdata', + // fired when parsing id3 is completed - data: { id : demuxer id, frag: fragment object, samples : [ id3 samples pes ] } + FRAG_PARSING_METADATA: 'hlsFragParsingMetadata', + // fired when data have been extracted from fragment - data: { id : demuxer id, frag: fragment object, data1 : moof MP4 box or TS fragments, data2 : mdat MP4 box or null} + FRAG_PARSING_DATA: 'hlsFragParsingData', + // fired when fragment parsing is completed - data: { id : demuxer id, frag: fragment object } + FRAG_PARSED: 'hlsFragParsed', + // fired when fragment remuxed MP4 boxes have all been appended into SourceBuffer - data: { id : demuxer id, frag : fragment object, stats : { trequest, tfirst, tload, tparsed, tbuffered, length, bwEstimate } } + FRAG_BUFFERED: 'hlsFragBuffered', + // fired when fragment matching with current media position is changing - data : { id : demuxer id, frag : fragment object } + FRAG_CHANGED: 'hlsFragChanged', + // Identifier for a FPS drop event - data: { curentDropped, currentDecoded, totalDroppedFrames } + FPS_DROP: 'hlsFpsDrop', + // triggered when FPS drop triggers auto level capping - data: { level, droppedlevel } + FPS_DROP_LEVEL_CAPPING: 'hlsFpsDropLevelCapping', + // Identifier for an error event - data: { type : error type, details : error details, fatal : if true, hls.js cannot/will not try to recover, if false, hls.js will try to recover,other error specific data } + ERROR: 'hlsError', + // fired when hls.js instance starts destroying. Different from MEDIA_DETACHED as one could want to detach and reattach a media to the instance of hls.js to handle mid-rolls for example - data: { } + DESTROYING: 'hlsDestroying', + // fired when a decrypt key loading starts - data: { frag : fragment object } + KEY_LOADING: 'hlsKeyLoading', + // fired when a decrypt key loading is completed - data: { frag : fragment object, payload : key payload, stats : { trequest, tfirst, tload, length } } + KEY_LOADED: 'hlsKeyLoaded', + // fired upon stream controller state transitions - data: { previousState, nextState } + STREAM_STATE_TRANSITION: 'hlsStreamStateTransition' +}; + +export default HlsEvents; diff --git a/cmd/mjpeg-player/hlsjs/hls.js b/cmd/mjpeg-player/hlsjs/hls.js new file mode 100644 index 00000000..88107af2 --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/hls.js @@ -0,0 +1,674 @@ +import * as URLToolkit from 'url-toolkit'; + +import { + ErrorTypes, + ErrorDetails +} from './errors'; + +import PlaylistLoader from './loader/playlist-loader'; +import FragmentLoader from './loader/fragment-loader'; +import KeyLoader from './loader/key-loader'; + +import { FragmentTracker } from './controller/fragment-tracker'; +import StreamController from './controller/stream-controller'; +import LevelController from './controller/level-controller'; +import ID3TrackController from './controller/id3-track-controller'; + +import { isSupported } from './is-supported'; +import { logger, enableLogs } from './utils/logger'; +import { hlsDefaultConfig, HlsConfig } from './config'; + +import HlsEvents from './events'; + +import { Observer } from './observer'; + +/** + * @module Hls + * @class + * @constructor + */ +export default class Hls extends Observer { + public static defaultConfig?: HlsConfig; + public config: HlsConfig; + + private _autoLevelCapping: number; + private abrController: any; + private capLevelController: any; + private levelController: any; + private streamController: any; + private networkControllers: any[]; + private audioTrackController: any; + private subtitleTrackController: any; + private emeController: any; + private coreComponents: any[]; + private media: HTMLMediaElement | null = null; + private url: string | null = null; + + /** + * @type {string} + */ + static get version (): string { + return __VERSION__; + } + + /** + * @type {boolean} + */ + static isSupported (): boolean { + return isSupported(); + } + + /** + * @type {HlsEvents} + */ + static get Events () { + return HlsEvents; + } + + /** + * @type {HlsErrorTypes} + */ + static get ErrorTypes () { + return ErrorTypes; + } + + /** + * @type {HlsErrorDetails} + */ + static get ErrorDetails () { + return ErrorDetails; + } + + /** + * @type {HlsConfig} + */ + static get DefaultConfig (): HlsConfig { + if (!Hls.defaultConfig) { + return hlsDefaultConfig; + } + + return Hls.defaultConfig; + } + + /** + * @type {HlsConfig} + */ + static set DefaultConfig (defaultConfig: HlsConfig) { + Hls.defaultConfig = defaultConfig; + } + + /** + * Creates an instance of an HLS client that can attach to exactly one `HTMLMediaElement`. + * + * @constructs Hls + * @param {HlsConfig} config + */ + constructor (userConfig: Partial = {}) { + super(); + + const defaultConfig = Hls.DefaultConfig; + + if ((userConfig.liveSyncDurationCount || userConfig.liveMaxLatencyDurationCount) && (userConfig.liveSyncDuration || userConfig.liveMaxLatencyDuration)) { + throw new Error('Illegal hls.js config: don\'t mix up liveSyncDurationCount/liveMaxLatencyDurationCount and liveSyncDuration/liveMaxLatencyDuration'); + } + + // Shallow clone + this.config = { + ...defaultConfig, + ...userConfig + }; + + const { config } = this; + + if (config.liveMaxLatencyDurationCount !== void 0 && config.liveMaxLatencyDurationCount <= config.liveSyncDurationCount) { + throw new Error('Illegal hls.js config: "liveMaxLatencyDurationCount" must be gt "liveSyncDurationCount"'); + } + + if (config.liveMaxLatencyDuration !== void 0 && (config.liveSyncDuration === void 0 || config.liveMaxLatencyDuration <= config.liveSyncDuration)) { + throw new Error('Illegal hls.js config: "liveMaxLatencyDuration" must be gt "liveSyncDuration"'); + } + + enableLogs(config.debug); + + this._autoLevelCapping = -1; + + // core controllers and network loaders + + /** + * @member {AbrController} abrController + */ + const abrController = this.abrController = new config.abrController(this); // eslint-disable-line new-cap + const bufferController = new config.bufferController(this); // eslint-disable-line new-cap + const capLevelController = this.capLevelController = new config.capLevelController(this); // eslint-disable-line new-cap + const fpsController = new config.fpsController(this); // eslint-disable-line new-cap + const playListLoader = new PlaylistLoader(this); + const fragmentLoader = new FragmentLoader(this); + const keyLoader = new KeyLoader(this); + const id3TrackController = new ID3TrackController(this); + + // network controllers + + /** + * @member {LevelController} levelController + */ + const levelController = this.levelController = new LevelController(this); + + // FIXME: FragmentTracker must be defined before StreamController because the order of event handling is important + const fragmentTracker = new FragmentTracker(this); + + /** + * @member {StreamController} streamController + */ + const streamController = this.streamController = new StreamController(this, fragmentTracker); + + let networkControllers = [levelController, streamController]; + + // optional audio stream controller + /** + * @var {ICoreComponent | Controller} + */ + let Controller = config.audioStreamController; + if (Controller) { + networkControllers.push(new Controller(this, fragmentTracker)); + } + + /** + * @member {INetworkController[]} networkControllers + */ + this.networkControllers = networkControllers; + + /** + * @var {ICoreComponent[]} + */ + const coreComponents = [ + playListLoader, + fragmentLoader, + keyLoader, + abrController, + bufferController, + capLevelController, + fpsController, + id3TrackController, + fragmentTracker + ]; + + // optional audio track and subtitle controller + Controller = config.audioTrackController; + if (Controller) { + const audioTrackController = new Controller(this); + + /** + * @member {AudioTrackController} audioTrackController + */ + this.audioTrackController = audioTrackController; + coreComponents.push(audioTrackController); + } + + Controller = config.subtitleTrackController; + if (Controller) { + const subtitleTrackController = new Controller(this); + + /** + * @member {SubtitleTrackController} subtitleTrackController + */ + this.subtitleTrackController = subtitleTrackController; + networkControllers.push(subtitleTrackController); + } + + Controller = config.emeController; + if (Controller) { + const emeController = new Controller(this); + + /** + * @member {EMEController} emeController + */ + this.emeController = emeController; + coreComponents.push(emeController); + } + + // optional subtitle controllers + Controller = config.subtitleStreamController; + if (Controller) { + networkControllers.push(new Controller(this, fragmentTracker)); + } + Controller = config.timelineController; + if (Controller) { + coreComponents.push(new Controller(this)); + } + + /** + * @member {ICoreComponent[]} + */ + this.coreComponents = coreComponents; + } + + /** + * Dispose of the instance + */ + destroy () { + logger.log('destroy'); + this.trigger(HlsEvents.DESTROYING); + this.detachMedia(); + this.coreComponents.concat(this.networkControllers).forEach(component => { + component.destroy(); + }); + this.url = null; + this.removeAllListeners(); + this._autoLevelCapping = -1; + } + + /** + * Attach a media element + * @param {HTMLMediaElement} media + */ + attachMedia (media: HTMLMediaElement) { + logger.log('attachMedia'); + this.media = media; + this.trigger(HlsEvents.MEDIA_ATTACHING, { media: media }); + } + + /** + * Detach from the media + */ + detachMedia () { + logger.log('detachMedia'); + this.trigger(HlsEvents.MEDIA_DETACHING); + this.media = null; + } + + /** + * Set the source URL. Can be relative or absolute. + * @param {string} url + */ + loadSource (url: string) { + url = URLToolkit.buildAbsoluteURL(window.location.href, url, { alwaysNormalize: true }); + logger.log(`loadSource:${url}`); + this.url = url; + // when attaching to a source URL, trigger a playlist load + this.trigger(HlsEvents.MANIFEST_LOADING, { url: url }); + } + + /** + * Start loading data from the stream source. + * Depending on default config, client starts loading automatically when a source is set. + * + * @param {number} startPosition Set the start position to stream from + * @default -1 None (from earliest point) + */ + startLoad (startPosition: number = -1) { + logger.log(`startLoad(${startPosition})`); + this.networkControllers.forEach(controller => { + controller.startLoad(startPosition); + }); + } + + /** + * Stop loading of any stream data. + */ + stopLoad () { + logger.log('stopLoad'); + this.networkControllers.forEach(controller => { + controller.stopLoad(); + }); + } + + /** + * Swap through possible audio codecs in the stream (for example to switch from stereo to 5.1) + */ + swapAudioCodec () { + logger.log('swapAudioCodec'); + this.streamController.swapAudioCodec(); + } + + /** + * When the media-element fails, this allows to detach and then re-attach it + * as one call (convenience method). + * + * Automatic recovery of media-errors by this process is configurable. + */ + recoverMediaError () { + logger.log('recoverMediaError'); + let media = this.media; + this.detachMedia(); + if (media) { + this.attachMedia(media); + } + } + + /** + * @type {QualityLevel[]} + */ + // todo(typescript-levelController) + get levels (): any[] { + return this.levelController.levels; + } + + /** + * Index of quality level currently played + * @type {number} + */ + get currentLevel (): number { + return this.streamController.currentLevel; + } + + /** + * Set quality level index immediately . + * This will flush the current buffer to replace the quality asap. + * That means playback will interrupt at least shortly to re-buffer and re-sync eventually. + * @type {number} -1 for automatic level selection + */ + set currentLevel (newLevel: number) { + logger.log(`set currentLevel:${newLevel}`); + this.loadLevel = newLevel; + this.streamController.immediateLevelSwitch(); + } + + /** + * Index of next quality level loaded as scheduled by stream controller. + * @type {number} + */ + get nextLevel (): number { + return this.streamController.nextLevel; + } + + /** + * Set quality level index for next loaded data. + * This will switch the video quality asap, without interrupting playback. + * May abort current loading of data, and flush parts of buffer (outside currently played fragment region). + * @type {number} -1 for automatic level selection + */ + set nextLevel (newLevel: number) { + logger.log(`set nextLevel:${newLevel}`); + this.levelController.manualLevel = newLevel; + this.streamController.nextLevelSwitch(); + } + + /** + * Return the quality level of the currently or last (of none is loaded currently) segment + * @type {number} + */ + get loadLevel (): number { + return this.levelController.level; + } + + /** + * Set quality level index for next loaded data in a conservative way. + * This will switch the quality without flushing, but interrupt current loading. + * Thus the moment when the quality switch will appear in effect will only be after the already existing buffer. + * @type {number} newLevel -1 for automatic level selection + */ + set loadLevel (newLevel: number) { + logger.log(`set loadLevel:${newLevel}`); + this.levelController.manualLevel = newLevel; + } + + /** + * get next quality level loaded + * @type {number} + */ + get nextLoadLevel (): number { + return this.levelController.nextLoadLevel; + } + + /** + * Set quality level of next loaded segment in a fully "non-destructive" way. + * Same as `loadLevel` but will wait for next switch (until current loading is done). + * @type {number} level + */ + set nextLoadLevel (level: number) { + this.levelController.nextLoadLevel = level; + } + + /** + * Return "first level": like a default level, if not set, + * falls back to index of first level referenced in manifest + * @type {number} + */ + get firstLevel (): number { + return Math.max(this.levelController.firstLevel, this.minAutoLevel); + } + + /** + * Sets "first-level", see getter. + * @type {number} + */ + set firstLevel (newLevel: number) { + logger.log(`set firstLevel:${newLevel}`); + this.levelController.firstLevel = newLevel; + } + + /** + * Return start level (level of first fragment that will be played back) + * if not overrided by user, first level appearing in manifest will be used as start level + * if -1 : automatic start level selection, playback will start from level matching download bandwidth + * (determined from download of first segment) + * @type {number} + */ + get startLevel (): number { + return this.levelController.startLevel; + } + + /** + * set start level (level of first fragment that will be played back) + * if not overrided by user, first level appearing in manifest will be used as start level + * if -1 : automatic start level selection, playback will start from level matching download bandwidth + * (determined from download of first segment) + * @type {number} newLevel + */ + set startLevel (newLevel: number) { + logger.log(`set startLevel:${newLevel}`); + // if not in automatic start level detection, ensure startLevel is greater than minAutoLevel + if (newLevel !== -1) { + newLevel = Math.max(newLevel, this.minAutoLevel); + } + + this.levelController.startLevel = newLevel; + } + + /** + * set dynamically set capLevelToPlayerSize against (`CapLevelController`) + * + * @type {boolean} + */ + set capLevelToPlayerSize (shouldStartCapping: boolean) { + const newCapLevelToPlayerSize = !!shouldStartCapping; + + if (newCapLevelToPlayerSize !== this.config.capLevelToPlayerSize) { + if (newCapLevelToPlayerSize) { + this.capLevelController.startCapping(); // If capping occurs, nextLevelSwitch will happen based on size. + } else { + this.capLevelController.stopCapping(); + this.autoLevelCapping = -1; + this.streamController.nextLevelSwitch(); // Now we're uncapped, get the next level asap. + } + + this.config.capLevelToPlayerSize = newCapLevelToPlayerSize; + } + } + + /** + * Capping/max level value that should be used by automatic level selection algorithm (`ABRController`) + * @type {number} + */ + get autoLevelCapping (): number { + return this._autoLevelCapping; + } + + /** + * get bandwidth estimate + * @type {number} + */ + get bandwidthEstimate (): number { + const bwEstimator = this.abrController._bwEstimator; + return bwEstimator ? bwEstimator.getEstimate() : NaN; + } + + /** + * Capping/max level value that should be used by automatic level selection algorithm (`ABRController`) + * @type {number} + */ + set autoLevelCapping (newLevel: number) { + logger.log(`set autoLevelCapping:${newLevel}`); + this._autoLevelCapping = newLevel; + } + + /** + * True when automatic level selection enabled + * @type {boolean} + */ + get autoLevelEnabled (): boolean { + return (this.levelController.manualLevel === -1); + } + + /** + * Level set manually (if any) + * @type {number} + */ + get manualLevel (): number { + return this.levelController.manualLevel; + } + + /** + * min level selectable in auto mode according to config.minAutoBitrate + * @type {number} + */ + get minAutoLevel (): number { + const { levels, config: { minAutoBitrate } } = this; + const len = levels ? levels.length : 0; + + for (let i = 0; i < len; i++) { + const levelNextBitrate = levels[i].realBitrate + ? Math.max(levels[i].realBitrate, levels[i].bitrate) + : levels[i].bitrate; + + if (levelNextBitrate > minAutoBitrate) { + return i; + } + } + + return 0; + } + + /** + * max level selectable in auto mode according to autoLevelCapping + * @type {number} + */ + get maxAutoLevel (): number { + const { levels, autoLevelCapping } = this; + + let maxAutoLevel; + if (autoLevelCapping === -1 && levels && levels.length) { + maxAutoLevel = levels.length - 1; + } else { + maxAutoLevel = autoLevelCapping; + } + + return maxAutoLevel; + } + + /** + * next automatically selected quality level + * @type {number} + */ + get nextAutoLevel (): number { + // ensure next auto level is between min and max auto level + return Math.min(Math.max(this.abrController.nextAutoLevel, this.minAutoLevel), this.maxAutoLevel); + } + + /** + * this setter is used to force next auto level. + * this is useful to force a switch down in auto mode: + * in case of load error on level N, hls.js can set nextAutoLevel to N-1 for example) + * forced value is valid for one fragment. upon succesful frag loading at forced level, + * this value will be resetted to -1 by ABR controller. + * @type {number} + */ + set nextAutoLevel (nextLevel: number) { + this.abrController.nextAutoLevel = Math.max(this.minAutoLevel, nextLevel); + } + + /** + * @type {AudioTrack[]} + */ + // todo(typescript-audioTrackController) + get audioTracks (): any[] { + const audioTrackController = this.audioTrackController; + return audioTrackController ? audioTrackController.audioTracks : []; + } + + /** + * index of the selected audio track (index in audio track lists) + * @type {number} + */ + get audioTrack (): number { + const audioTrackController = this.audioTrackController; + return audioTrackController ? audioTrackController.audioTrack : -1; + } + + /** + * selects an audio track, based on its index in audio track lists + * @type {number} + */ + set audioTrack (audioTrackId: number) { + const audioTrackController = this.audioTrackController; + if (audioTrackController) { + audioTrackController.audioTrack = audioTrackId; + } + } + + /** + * @type {Seconds} + */ + get liveSyncPosition (): number { + return this.streamController.liveSyncPosition; + } + + /** + * get alternate subtitle tracks list from playlist + * @type {SubtitleTrack[]} + */ + // todo(typescript-subtitleTrackController) + get subtitleTracks (): any[] { + const subtitleTrackController = this.subtitleTrackController; + return subtitleTrackController ? subtitleTrackController.subtitleTracks : []; + } + + /** + * index of the selected subtitle track (index in subtitle track lists) + * @type {number} + */ + get subtitleTrack (): number { + const subtitleTrackController = this.subtitleTrackController; + return subtitleTrackController ? subtitleTrackController.subtitleTrack : -1; + } + + /** + * select an subtitle track, based on its index in subtitle track lists + * @type {number} + */ + set subtitleTrack (subtitleTrackId: number) { + const subtitleTrackController = this.subtitleTrackController; + if (subtitleTrackController) { + subtitleTrackController.subtitleTrack = subtitleTrackId; + } + } + + /** + * @type {boolean} + */ + get subtitleDisplay (): boolean { + const subtitleTrackController = this.subtitleTrackController; + return subtitleTrackController ? subtitleTrackController.subtitleDisplay : false; + } + + /** + * Enable/disable subtitle display rendering + * @type {boolean} + */ + set subtitleDisplay (value: boolean) { + const subtitleTrackController = this.subtitleTrackController; + if (subtitleTrackController) { + subtitleTrackController.subtitleDisplay = value; + } + } +} diff --git a/cmd/mjpeg-player/hlsjs/loader/fragment-loader.js b/cmd/mjpeg-player/hlsjs/loader/fragment-loader.js new file mode 100644 index 00000000..ac5f3d51 --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/loader/fragment-loader.js @@ -0,0 +1,116 @@ +/* + * Fragment Loader +*/ + +import Event from '../events'; +import EventHandler from '../event-handler'; +import { ErrorTypes, ErrorDetails } from '../errors'; +import { logger } from '../utils/logger'; + +class FragmentLoader extends EventHandler { + constructor (hls) { + super(hls, Event.FRAG_LOADING); + this.loaders = {}; + } + + destroy () { + let loaders = this.loaders; + for (let loaderName in loaders) { + let loader = loaders[loaderName]; + if (loader) { + loader.destroy(); + } + } + this.loaders = {}; + + super.destroy(); + } + + onFragLoading (data) { + const frag = data.frag, + type = frag.type, + loaders = this.loaders, + config = this.hls.config, + FragmentILoader = config.fLoader, + DefaultILoader = config.loader; + + // reset fragment state + frag.loaded = 0; + + let loader = loaders[type]; + if (loader) { + logger.warn(`abort previous fragment loader for type: ${type}`); + loader.abort(); + } + + loader = loaders[type] = frag.loader = + config.fLoader ? new FragmentILoader(config) : new DefaultILoader(config); + + let loaderContext, loaderConfig, loaderCallbacks; + + loaderContext = { url: frag.url, frag: frag, responseType: 'arraybuffer', progressData: false }; + + let start = frag.byteRangeStartOffset, + end = frag.byteRangeEndOffset; + + if (Number.isFinite(start) && Number.isFinite(end)) { + loaderContext.rangeStart = start; + loaderContext.rangeEnd = end; + } + + loaderConfig = { + timeout: config.fragLoadingTimeOut, + maxRetry: 0, + retryDelay: 0, + maxRetryDelay: config.fragLoadingMaxRetryTimeout + }; + + loaderCallbacks = { + onSuccess: this.loadsuccess.bind(this), + onError: this.loaderror.bind(this), + onTimeout: this.loadtimeout.bind(this), + onProgress: this.loadprogress.bind(this) + }; + + loader.load(loaderContext, loaderConfig, loaderCallbacks); + } + + loadsuccess (response, stats, context, networkDetails = null) { + let payload = response.data, frag = context.frag; + // detach fragment loader on load success + frag.loader = undefined; + this.loaders[frag.type] = undefined; + this.hls.trigger(Event.FRAG_LOADED, { payload: payload, frag: frag, stats: stats, networkDetails: networkDetails }); + } + + loaderror (response, context, networkDetails = null) { + const frag = context.frag; + let loader = frag.loader; + if (loader) { + loader.abort(); + } + + this.loaders[frag.type] = undefined; + this.hls.trigger(Event.ERROR, { type: ErrorTypes.NETWORK_ERROR, details: ErrorDetails.FRAG_LOAD_ERROR, fatal: false, frag: context.frag, response: response, networkDetails: networkDetails }); + } + + loadtimeout (stats, context, networkDetails = null) { + const frag = context.frag; + let loader = frag.loader; + if (loader) { + loader.abort(); + } + + this.loaders[frag.type] = undefined; + this.hls.trigger(Event.ERROR, { type: ErrorTypes.NETWORK_ERROR, details: ErrorDetails.FRAG_LOAD_TIMEOUT, fatal: false, frag: context.frag, networkDetails: networkDetails }); + } + + // data will be used for progressive parsing + loadprogress (stats, context, data, networkDetails = null) { // jshint ignore:line + let frag = context.frag; + frag.loaded = stats.loaded; + this.hls.trigger(Event.FRAG_LOAD_PROGRESS, { frag: frag, stats: stats, networkDetails: networkDetails }); + } +} + +export default FragmentLoader; diff --git a/cmd/mjpeg-player/hlsjs/loader/fragment.js b/cmd/mjpeg-player/hlsjs/loader/fragment.js new file mode 100644 index 00000000..fb7c0532 --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/loader/fragment.js @@ -0,0 +1,201 @@ + +import { buildAbsoluteURL } from 'url-toolkit'; +import { logger } from '../utils/logger'; +import LevelKey from './level-key'; +import { PlaylistLevelType } from '../types/loader'; + +export enum ElementaryStreamTypes { + AUDIO = 'audio', + VIDEO = 'video', +} + +export default class Fragment { + private _url: string | null = null; + private _byteRange: number[] | null = null; + private _decryptdata: LevelKey | null = null; + + // Holds the types of data this fragment supports + private _elementaryStreams: Record = { + [ElementaryStreamTypes.AUDIO]: false, + [ElementaryStreamTypes.VIDEO]: false + }; + + // deltaPTS tracks the change in presentation timestamp between fragments + public deltaPTS: number = 0; + + public rawProgramDateTime: string | null = null; + public programDateTime: number | null = null; + public title: string | null = null; + public tagList: Array = []; + + // TODO: Move at least baseurl to constructor. + // Currently we do a two-pass construction as use the Fragment class almost like a object for holding parsing state. + // It may make more sense to just use a POJO to keep state during the parsing phase. + // Have Fragment be the representation once we have a known state? + // Something to think on. + + // Discontinuity Counter + public cc!: number; + + public type!: PlaylistLevelType; + // relurl is the portion of the URL that comes from inside the playlist. + public relurl!: string; + // baseurl is the URL to the playlist + public baseurl!: string; + // EXTINF has to be present for a m3u8 to be considered valid + public duration!: number; + // When this segment starts in the timeline + public start!: number; + // sn notates the sequence number for a segment, and if set to a string can be 'initSegment' + public sn: number | 'initSegment' = 0; + + public urlId: number = 0; + // level matches this fragment to a index playlist + public level: number = 0; + // levelkey is the EXT-X-KEY that applies to this segment for decryption + // core difference from the private field _decryptdata is the lack of the initialized IV + // _decryptdata will set the IV for this segment based on the segment number in the fragment + public levelkey?: LevelKey; + + // TODO(typescript-xhrloader) + public loader: any; + + // setByteRange converts a EXT-X-BYTERANGE attribute into a two element array + setByteRange (value: string, previousFrag?: Fragment) { + const params = value.split('@', 2); + const byteRange: number[] = []; + if (params.length === 1) { + byteRange[0] = previousFrag ? previousFrag.byteRangeEndOffset : 0; + } else { + byteRange[0] = parseInt(params[1]); + } + byteRange[1] = parseInt(params[0]) + byteRange[0]; + this._byteRange = byteRange; + } + + get url () { + if (!this._url && this.relurl) { + this._url = buildAbsoluteURL(this.baseurl, this.relurl, { alwaysNormalize: true }); + } + + return this._url; + } + + set url (value) { + this._url = value; + } + + get byteRange (): number[] { + if (!this._byteRange) { + return []; + } + + return this._byteRange; + } + + /** + * @type {number} + */ + get byteRangeStartOffset () { + return this.byteRange[0]; + } + + get byteRangeEndOffset () { + return this.byteRange[1]; + } + + get decryptdata (): LevelKey | null { + if (!this.levelkey && !this._decryptdata) { + return null; + } + + if (!this._decryptdata && this.levelkey) { + let sn = this.sn; + if (typeof sn !== 'number') { + // We are fetching decryption data for a initialization segment + // If the segment was encrypted with AES-128 + // It must have an IV defined. We cannot substitute the Segment Number in. + if (this.levelkey && this.levelkey.method === 'AES-128' && !this.levelkey.iv) { + logger.warn(`missing IV for initialization segment with method="${this.levelkey.method}" - compliance issue`); + } + + /* + Be converted to a Number. + 'initSegment' will become NaN. + NaN, which when converted through ToInt32() -> +0. + --- + Explicitly set sn to resulting value from implicit conversions 'initSegment' values for IV generation. + */ + sn = 0; + } + this._decryptdata = this.setDecryptDataFromLevelKey(this.levelkey, sn); + } + + return this._decryptdata; + } + + get endProgramDateTime () { + if (this.programDateTime === null) { + return null; + } + + if (!Number.isFinite(this.programDateTime)) { + return null; + } + + let duration = !Number.isFinite(this.duration) ? 0 : this.duration; + + return this.programDateTime + (duration * 1000); + } + + get encrypted () { + return !!((this.decryptdata && this.decryptdata.uri !== null) && (this.decryptdata.key === null)); + } + + /** + * @param {ElementaryStreamTypes} type + */ + addElementaryStream (type: ElementaryStreamTypes) { + this._elementaryStreams[type] = true; + } + + /** + * @param {ElementaryStreamTypes} type + */ + hasElementaryStream (type: ElementaryStreamTypes) { + return this._elementaryStreams[type] === true; + } + + /** + * Utility method for parseLevelPlaylist to create an initialization vector for a given segment + * @param {number} segmentNumber - segment number to generate IV with + * @returns {Uint8Array} + */ + createInitializationVector (segmentNumber: number): Uint8Array { + let uint8View = new Uint8Array(16); + + for (let i = 12; i < 16; i++) { + uint8View[i] = (segmentNumber >> 8 * (15 - i)) & 0xff; + } + + return uint8View; + } + + /** + * Utility method for parseLevelPlaylist to get a fragment's decryption data from the currently parsed encryption key data + * @param levelkey - a playlist's encryption info + * @param segmentNumber - the fragment's segment number + * @returns {LevelKey} - an object to be applied as a fragment's decryptdata + */ + setDecryptDataFromLevelKey (levelkey: LevelKey, segmentNumber: number): LevelKey { + let decryptdata = levelkey; + + if (levelkey && levelkey.method && levelkey.uri && !levelkey.iv) { + decryptdata = new LevelKey(levelkey.baseuri, levelkey.reluri); + decryptdata.method = levelkey.method; + decryptdata.iv = this.createInitializationVector(segmentNumber); + } + + return decryptdata; + } +} diff --git a/cmd/mjpeg-player/hlsjs/loader/level-key.js b/cmd/mjpeg-player/hlsjs/loader/level-key.js new file mode 100644 index 00000000..f5abc95e --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/loader/level-key.js @@ -0,0 +1,24 @@ +import { buildAbsoluteURL } from 'url-toolkit'; + +export default class LevelKey { + private _uri: string | null = null; + + public baseuri: string; + public reluri: string; + public method: string | null = null; + public key: Uint8Array | null = null; + public iv: Uint8Array | null = null; + + constructor (baseURI: string, relativeURI: string) { + this.baseuri = baseURI; + this.reluri = relativeURI; + } + + get uri () { + if (!this._uri && this.reluri) { + this._uri = buildAbsoluteURL(this.baseuri, this.reluri, { alwaysNormalize: true }); + } + + return this._uri; + } +} diff --git a/cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js b/cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js new file mode 100644 index 00000000..ea4c2538 --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/loader/m3u8-parser.js @@ -0,0 +1,370 @@ +import * as URLToolkit from 'url-toolkit'; + +import Fragment from './fragment'; +import Level from './level'; +import LevelKey from './level-key'; + +import AttrList from '../utils/attr-list'; +import { logger } from '../utils/logger'; +import { isCodecType, CodecType } from '../utils/codecs'; +import { MediaPlaylist, AudioGroup, MediaPlaylistType } from '../types/media-playlist'; +import { PlaylistLevelType } from '../types/loader'; + +/** + * M3U8 parser + * @module + */ + +// https://regex101.com is your friend +const MASTER_PLAYLIST_REGEX = /#EXT-X-STREAM-INF:([^\n\r]*)[\r\n]+([^\r\n]+)/g; +const MASTER_PLAYLIST_MEDIA_REGEX = /#EXT-X-MEDIA:(.*)/g; + +const LEVEL_PLAYLIST_REGEX_FAST = new RegExp([ + /#EXTINF:\s*(\d*(?:\.\d+)?)(?:,(.*)\s+)?/.source, // duration (#EXTINF:,), group 1 => duration, group 2 => title + /|(?!#)([\S+ ?]+)/.source, // segment URI, group 3 => the URI (note newline is not eaten) + /|#EXT-X-BYTERANGE:*(.+)/.source, // next segment's byterange, group 4 => range spec (x@y) + /|#EXT-X-PROGRAM-DATE-TIME:(.+)/.source, // next segment's program date/time group 5 => the datetime spec + /|#.*/.source // All other non-segment oriented tags will match with all groups empty +].join(''), 'g'); + +const LEVEL_PLAYLIST_REGEX_SLOW = /(?:(?:#(EXTM3U))|(?:#EXT-X-(PLAYLIST-TYPE):(.+))|(?:#EXT-X-(MEDIA-SEQUENCE): *(\d+))|(?:#EXT-X-(TARGETDURATION): *(\d+))|(?:#EXT-X-(KEY):(.+))|(?:#EXT-X-(START):(.+))|(?:#EXT-X-(ENDLIST))|(?:#EXT-X-(DISCONTINUITY-SEQ)UENCE:(\d+))|(?:#EXT-X-(DIS)CONTINUITY))|(?:#EXT-X-(VERSION):(\d+))|(?:#EXT-X-(MAP):(.+))|(?:(#)([^:]*):(.*))|(?:(#)(.*))(?:.*)\r?\n?/; + +const MP4_REGEX_SUFFIX = /\.(mp4|m4s|m4v|m4a)$/i; + +export default class M3U8Parser { + static findGroup (groups: Array<AudioGroup>, mediaGroupId: string): AudioGroup | undefined { + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + if (group.id === mediaGroupId) { + return group; + } + } + } + + static convertAVC1ToAVCOTI (codec) { + let avcdata = codec.split('.'); + let result; + if (avcdata.length > 2) { + result = avcdata.shift() + '.'; + result += parseInt(avcdata.shift()).toString(16); + result += ('000' + parseInt(avcdata.shift()).toString(16)).substr(-4); + } else { + result = codec; + } + return result; + } + + static resolve (url, baseUrl) { + return URLToolkit.buildAbsoluteURL(baseUrl, url, { alwaysNormalize: true }); + } + + static parseMasterPlaylist (string: string, baseurl: string) { + // TODO(typescript-level) + let levels: Array<any> = []; + MASTER_PLAYLIST_REGEX.lastIndex = 0; + + // TODO(typescript-level) + function setCodecs (codecs: Array<string>, level: any) { + ['video', 'audio'].forEach((type: CodecType) => { + const filtered = codecs.filter((codec) => isCodecType(codec, type)); + if (filtered.length) { + const preferred = filtered.filter((codec) => { + return codec.lastIndexOf('avc1', 0) === 0 || codec.lastIndexOf('mp4a', 0) === 0; + }); + level[`${type}Codec`] = preferred.length > 0 ? preferred[0] : filtered[0]; + + // remove from list + codecs = codecs.filter((codec) => filtered.indexOf(codec) === -1); + } + }); + + level.unknownCodecs = codecs; + } + + let result: RegExpExecArray | null; + while ((result = MASTER_PLAYLIST_REGEX.exec(string)) != null) { + // TODO(typescript-level) + const level: any = {}; + + const attrs = level.attrs = new AttrList(result[1]); + level.url = M3U8Parser.resolve(result[2], baseurl); + + const resolution = attrs.decimalResolution('RESOLUTION'); + if (resolution) { + level.width = resolution.width; + level.height = resolution.height; + } + level.bitrate = attrs.decimalInteger('AVERAGE-BANDWIDTH') || attrs.decimalInteger('BANDWIDTH'); + level.name = attrs.NAME; + + setCodecs([].concat((attrs.CODECS || '').split(/[ ,]+/)), level); + + if (level.videoCodec && level.videoCodec.indexOf('avc1') !== -1) { + level.videoCodec = M3U8Parser.convertAVC1ToAVCOTI(level.videoCodec); + } + + levels.push(level); + } + return levels; + } + + static parseMasterPlaylistMedia (string: string, baseurl: string, type: MediaPlaylistType, audioGroups: Array<AudioGroup> = []): Array<MediaPlaylist> { + let result: RegExpExecArray | null; + let medias: Array<MediaPlaylist> = []; + let id = 0; + MASTER_PLAYLIST_MEDIA_REGEX.lastIndex = 0; + while ((result = MASTER_PLAYLIST_MEDIA_REGEX.exec(string)) !== null) { + const attrs = new AttrList(result[1]); + if (attrs.TYPE === type) { + const media: MediaPlaylist = { + id: id++, + groupId: attrs['GROUP-ID'], + name: attrs.NAME || attrs.LANGUAGE, + type, + default: (attrs.DEFAULT === 'YES'), + autoselect: (attrs.AUTOSELECT === 'YES'), + forced: (attrs.FORCED === 'YES'), + lang: attrs.LANGUAGE + }; + + if (attrs.URI) { + media.url = M3U8Parser.resolve(attrs.URI, baseurl); + } + + if (audioGroups.length) { + // If there are audio groups signalled in the manifest, let's look for a matching codec string for this track + const groupCodec = M3U8Parser.findGroup(audioGroups, media.groupId); + + // If we don't find the track signalled, lets use the first audio groups codec we have + // Acting as a best guess + media.audioCodec = groupCodec ? groupCodec.codec : audioGroups[0].codec; + } + + medias.push(media); + } + } + return medias; + } + + static parseLevelPlaylist (string: string, baseurl: string, id: number, type: PlaylistLevelType, levelUrlId: number) { + let currentSN = 0; + let totalduration = 0; + let level = new Level(baseurl); + let discontinuityCounter = 0; + let prevFrag: Fragment | null = null; + let frag: Fragment | null = new Fragment(); + let result: RegExpExecArray | RegExpMatchArray | null; + let i: number; + let levelkey: LevelKey | undefined; + + let firstPdtIndex = null; + + LEVEL_PLAYLIST_REGEX_FAST.lastIndex = 0; + + while ((result = LEVEL_PLAYLIST_REGEX_FAST.exec(string)) !== null) { + const duration = result[1]; + if (duration) { // INF + frag.duration = parseFloat(duration); + // avoid sliced strings https://github.com/video-dev/hls.js/issues/939 + const title = (' ' + result[2]).slice(1); + frag.title = title || null; + frag.tagList.push(title ? [ 'INF', duration, title ] : [ 'INF', duration ]); + } else if (result[3]) { // url + if (Number.isFinite(frag.duration)) { + const sn = currentSN++; + frag.type = type; + frag.start = totalduration; + if (levelkey) { + frag.levelkey = levelkey; + } + frag.sn = sn; + frag.level = id; + frag.cc = discontinuityCounter; + frag.urlId = levelUrlId; + frag.baseurl = baseurl; + // avoid sliced strings https://github.com/video-dev/hls.js/issues/939 + frag.relurl = (' ' + result[3]).slice(1); + assignProgramDateTime(frag, prevFrag); + + level.fragments.push(frag); + prevFrag = frag; + totalduration += frag.duration; + + frag = new Fragment(); + } + } else if (result[4]) { // X-BYTERANGE + const data = (' ' + result[4]).slice(1); + if (prevFrag) { + frag.setByteRange(data, prevFrag); + } else { + frag.setByteRange(data); + } + } else if (result[5]) { // PROGRAM-DATE-TIME + // avoid sliced strings https://github.com/video-dev/hls.js/issues/939 + frag.rawProgramDateTime = (' ' + result[5]).slice(1); + frag.tagList.push(['PROGRAM-DATE-TIME', frag.rawProgramDateTime]); + if (firstPdtIndex === null) { + firstPdtIndex = level.fragments.length; + } + } else { + result = result[0].match(LEVEL_PLAYLIST_REGEX_SLOW); + if (!result) { + logger.warn('No matches on slow regex match for level playlist!'); + continue; + } + for (i = 1; i < result.length; i++) { + if (typeof result[i] !== 'undefined') { + break; + } + } + + // avoid sliced strings https://github.com/video-dev/hls.js/issues/939 + const value1 = (' ' + result[i + 1]).slice(1); + const value2 = (' ' + result[i + 2]).slice(1); + + switch (result[i]) { + case '#': + frag.tagList.push(value2 ? [ value1, value2 ] : [ value1 ]); + break; + case 'PLAYLIST-TYPE': + level.type = value1.toUpperCase(); + break; + case 'MEDIA-SEQUENCE': + currentSN = level.startSN = parseInt(value1); + break; + case 'TARGETDURATION': + level.targetduration = parseFloat(value1); + break; + case 'VERSION': + level.version = parseInt(value1); + break; + case 'EXTM3U': + break; + case 'ENDLIST': + level.live = false; + break; + case 'DIS': + discontinuityCounter++; + frag.tagList.push(['DIS']); + break; + case 'DISCONTINUITY-SEQ': + discontinuityCounter = parseInt(value1); + break; + case 'KEY': { + // https://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-3.4.4 + const decryptparams = value1; + const keyAttrs = new AttrList(decryptparams); + const decryptmethod = keyAttrs.enumeratedString('METHOD'); + const decrypturi = keyAttrs.URI; + const decryptiv = keyAttrs.hexadecimalInteger('IV'); + + if (decryptmethod) { + levelkey = new LevelKey(baseurl, decrypturi); + if ((decrypturi) && (['AES-128', 'SAMPLE-AES', 'SAMPLE-AES-CENC'].indexOf(decryptmethod) >= 0)) { + levelkey.method = decryptmethod; + levelkey.key = null; + // Initialization Vector (IV) + levelkey.iv = decryptiv; + } + } + break; + } + case 'START': { + const startAttrs = new AttrList(value1); + const startTimeOffset = startAttrs.decimalFloatingPoint('TIME-OFFSET'); + // TIME-OFFSET can be 0 + if (Number.isFinite(startTimeOffset)) { + level.startTimeOffset = startTimeOffset; + } + break; + } + case 'MAP': { + const mapAttrs = new AttrList(value1); + frag.relurl = mapAttrs.URI; + if (mapAttrs.BYTERANGE) { + frag.setByteRange(mapAttrs.BYTERANGE); + } + frag.baseurl = baseurl; + frag.level = id; + frag.type = type; + frag.sn = 'initSegment'; + level.initSegment = frag; + frag = new Fragment(); + frag.rawProgramDateTime = level.initSegment.rawProgramDateTime; + break; + } + default: + logger.warn(`line parsed but not handled: ${result}`); + break; + } + } + } + frag = prevFrag; + // logger.log('found ' + level.fragments.length + ' fragments'); + if (frag && !frag.relurl) { + level.fragments.pop(); + totalduration -= frag.duration; + } + level.totalduration = totalduration; + level.averagetargetduration = totalduration / level.fragments.length; + level.endSN = currentSN - 1; + level.startCC = level.fragments[0] ? level.fragments[0].cc : 0; + level.endCC = discontinuityCounter; + + if (!level.initSegment && level.fragments.length) { + // this is a bit lurky but HLS really has no other way to tell us + // if the fragments are TS or MP4, except if we download them :/ + // but this is to be able to handle SIDX. + if (level.fragments.every((frag) => MP4_REGEX_SUFFIX.test(frag.relurl))) { + logger.warn('MP4 fragments found but no init segment (probably no MAP, incomplete M3U8), trying to fetch SIDX'); + + frag = new Fragment(); + frag.relurl = level.fragments[0].relurl; + frag.baseurl = baseurl; + frag.level = id; + frag.type = type; + frag.sn = 'initSegment'; + + level.initSegment = frag; + level.needSidxRanges = true; + } + } + + /** + * Backfill any missing PDT values + "If the first EXT-X-PROGRAM-DATE-TIME tag in a Playlist appears after + one or more Media Segment URIs, the client SHOULD extrapolate + backward from that tag (using EXTINF durations and/or media + timestamps) to associate dates with those segments." + * We have already extrapolated forward, but all fragments up to the first instance of PDT do not have their PDTs + * computed. + */ + if (firstPdtIndex) { + backfillProgramDateTimes(level.fragments, firstPdtIndex); + } + + return level; + } +} + +function backfillProgramDateTimes (fragments, startIndex) { + let fragPrev = fragments[startIndex]; + for (let i = startIndex - 1; i >= 0; i--) { + const frag = fragments[i]; + frag.programDateTime = fragPrev.programDateTime - (frag.duration * 1000); + fragPrev = frag; + } +} + +function assignProgramDateTime (frag, prevFrag) { + if (frag.rawProgramDateTime) { + frag.programDateTime = Date.parse(frag.rawProgramDateTime); + } else if (prevFrag && prevFrag.programDateTime) { + frag.programDateTime = prevFrag.endProgramDateTime; + } + + if (!Number.isFinite(frag.programDateTime)) { + frag.programDateTime = null; + frag.rawProgramDateTime = null; + } +} diff --git a/cmd/mjpeg-player/hlsjs/loader/playlist-loader.js b/cmd/mjpeg-player/hlsjs/loader/playlist-loader.js new file mode 100644 index 00000000..cd8c6c2b --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/loader/playlist-loader.js @@ -0,0 +1,530 @@ +/** + * PlaylistLoader - delegate for media manifest/playlist loading tasks. Takes care of parsing media to internal data-models. + * + * Once loaded, dispatches events with parsed data-models of manifest/levels/audio/subtitle tracks. + * + * Uses loader(s) set in config to do actual internal loading of resource tasks. + * + * @module + * + */ + +import Event from '../events'; +import EventHandler from '../event-handler'; +import { ErrorTypes, ErrorDetails } from '../errors'; +import { logger } from '../utils/logger'; +import { Loader, PlaylistContextType, PlaylistLoaderContext, PlaylistLevelType, LoaderCallbacks, LoaderResponse, LoaderStats, LoaderConfiguration } from '../types/loader'; +import MP4Demuxer from '../demux/mp4demuxer'; +import M3U8Parser from './m3u8-parser'; +import { AudioGroup } from '../types/media-playlist'; + +const { performance } = window; + +/** + * @constructor + */ +class PlaylistLoader extends EventHandler { + private loaders: Partial<Record<PlaylistContextType, Loader<PlaylistLoaderContext>>> = {}; + + /** + * @constructs + * @param {Hls} hls + */ + constructor (hls) { + super(hls, + Event.MANIFEST_LOADING, + Event.LEVEL_LOADING, + Event.AUDIO_TRACK_LOADING, + Event.SUBTITLE_TRACK_LOADING); + } + + /** + * @param {PlaylistContextType} type + * @returns {boolean} + */ + static canHaveQualityLevels (type: PlaylistContextType): boolean { + return (type !== PlaylistContextType.AUDIO_TRACK && + type !== PlaylistContextType.SUBTITLE_TRACK); + } + + /** + * Map context.type to LevelType + * @param {PlaylistLoaderContext} context + * @returns {LevelType} + */ + static mapContextToLevelType (context: PlaylistLoaderContext): PlaylistLevelType { + const { type } = context; + + switch (type) { + case PlaylistContextType.AUDIO_TRACK: + return PlaylistLevelType.AUDIO; + case PlaylistContextType.SUBTITLE_TRACK: + return PlaylistLevelType.SUBTITLE; + default: + return PlaylistLevelType.MAIN; + } + } + + static getResponseUrl (response: LoaderResponse, context: PlaylistLoaderContext): string { + let url = response.url; + // responseURL not supported on some browsers (it is used to detect URL redirection) + // data-uri mode also not supported (but no need to detect redirection) + if (url === undefined || url.indexOf('data:') === 0) { + // fallback to initial URL + url = context.url; + } + return url; + } + + /** + * Returns defaults or configured loader-type overloads (pLoader and loader config params) + * Default loader is XHRLoader (see utils) + * @param {PlaylistLoaderContext} context + * @returns {Loader} or other compatible configured overload + */ + createInternalLoader (context: PlaylistLoaderContext): Loader<PlaylistLoaderContext> { + const config = this.hls.config; + const PLoader = config.pLoader; + const Loader = config.loader; + // TODO(typescript-config): Verify once config is typed that InternalLoader always returns a Loader + const InternalLoader = PLoader || Loader; + + const loader = new InternalLoader(config); + + // TODO - Do we really need to assign the instance or if the dep has been lost + context.loader = loader; + this.loaders[context.type] = loader; + + return loader; + } + + getInternalLoader (context: PlaylistLoaderContext): Loader<PlaylistLoaderContext> | undefined { + return this.loaders[context.type]; + } + + resetInternalLoader (contextType: PlaylistContextType) { + if (this.loaders[contextType]) { + delete this.loaders[contextType]; + } + } + + /** + * Call `destroy` on all internal loader instances mapped (one per context type) + */ + destroyInternalLoaders () { + for (let contextType in this.loaders) { + let loader = this.loaders[contextType]; + if (loader) { + loader.destroy(); + } + + this.resetInternalLoader(contextType as PlaylistContextType); + } + } + + destroy () { + this.destroyInternalLoaders(); + + super.destroy(); + } + + onManifestLoading (data: { url: string; }) { + this.load({ + url: data.url, + type: PlaylistContextType.MANIFEST, + level: 0, + id: null, + responseType: 'text' + }); + } + + onLevelLoading (data: { url: string; level: number | null; id: number | null; }) { + this.load({ + url: data.url, + type: PlaylistContextType.LEVEL, + level: data.level, + id: data.id, + responseType: 'text' + }); + } + + onAudioTrackLoading (data: { url: string; id: number | null; }) { + this.load({ + url: data.url, + type: PlaylistContextType.AUDIO_TRACK, + level: null, + id: data.id, + responseType: 'text' + }); + } + + onSubtitleTrackLoading (data: { url: string; id: number | null; }) { + this.load({ + url: data.url, + type: PlaylistContextType.SUBTITLE_TRACK, + level: null, + id: data.id, + responseType: 'text' + }); + } + + load (context: PlaylistLoaderContext): boolean { + const config = this.hls.config; + + logger.debug(`Loading playlist of type ${context.type}, level: ${context.level}, id: ${context.id}`); + + // Check if a loader for this context already exists + let loader = this.getInternalLoader(context); + if (loader) { + const loaderContext = loader.context; + if (loaderContext && loaderContext.url === context.url) { // same URL can't overlap + logger.trace('playlist request ongoing'); + return false; + } else { + logger.warn(`aborting previous loader for type: ${context.type}`); + loader.abort(); + } + } + + let maxRetry: number; + let timeout: number; + let retryDelay: number; + let maxRetryDelay: number; + + // apply different configs for retries depending on + // context (manifest, level, audio/subs playlist) + switch (context.type) { + case PlaylistContextType.MANIFEST: + maxRetry = config.manifestLoadingMaxRetry; + timeout = config.manifestLoadingTimeOut; + retryDelay = config.manifestLoadingRetryDelay; + maxRetryDelay = config.manifestLoadingMaxRetryTimeout; + break; + case PlaylistContextType.LEVEL: + // Disable internal loader retry logic, since we are managing retries in Level Controller + maxRetry = 0; + maxRetryDelay = 0; + retryDelay = 0; + timeout = config.levelLoadingTimeOut; + // TODO Introduce retry settings for audio-track and subtitle-track, it should not use level retry config + break; + default: + maxRetry = config.levelLoadingMaxRetry; + timeout = config.levelLoadingTimeOut; + retryDelay = config.levelLoadingRetryDelay; + maxRetryDelay = config.levelLoadingMaxRetryTimeout; + break; + } + + loader = this.createInternalLoader(context); + + const loaderConfig: LoaderConfiguration = { + timeout, + maxRetry, + retryDelay, + maxRetryDelay + }; + + const loaderCallbacks: LoaderCallbacks<PlaylistLoaderContext> = { + onSuccess: this.loadsuccess.bind(this), + onError: this.loaderror.bind(this), + onTimeout: this.loadtimeout.bind(this) + }; + + logger.debug(`Calling internal loader delegate for URL: ${context.url}`); + loader.load(context, loaderConfig, loaderCallbacks); + + return true; + } + + loadsuccess (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown = null) { + if (context.isSidxRequest) { + this._handleSidxRequest(response, context); + this._handlePlaylistLoaded(response, stats, context, networkDetails); + return; + } + + this.resetInternalLoader(context.type); + if (typeof response.data !== 'string') { + throw new Error('expected responseType of "text" for PlaylistLoader'); + } + + const string = response.data; + + stats.tload = performance.now(); + // stats.mtime = new Date(target.getResponseHeader('Last-Modified')); + + // Validate if it is an M3U8 at all + if (string.indexOf('#EXTM3U') !== 0) { + this._handleManifestParsingError(response, context, 'no EXTM3U delimiter', networkDetails); + return; + } + + // Check if chunk-list or master. handle empty chunk list case (first EXTINF not signaled, but TARGETDURATION present) + if (string.indexOf('#EXTINF:') > 0 || string.indexOf('#EXT-X-TARGETDURATION:') > 0) { + this._handleTrackOrLevelPlaylist(response, stats, context, networkDetails); + } else { + this._handleMasterPlaylist(response, stats, context, networkDetails); + } + } + + loaderror (response: LoaderResponse, context: PlaylistLoaderContext, networkDetails = null) { + this._handleNetworkError(context, networkDetails, false, response); + } + + loadtimeout (stats: LoaderStats, context: PlaylistLoaderContext, networkDetails = null) { + this._handleNetworkError(context, networkDetails, true); + } + + // TODO(typescript-config): networkDetails can currently be a XHR or Fetch impl, + // but with custom loaders it could be generic investigate this further when config is typed + _handleMasterPlaylist (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown) { + const hls = this.hls; + const string = response.data as string; + + const url = PlaylistLoader.getResponseUrl(response, context); + const levels = M3U8Parser.parseMasterPlaylist(string, url); + if (!levels.length) { + this._handleManifestParsingError(response, context, 'no level found in manifest', networkDetails); + return; + } + + // multi level playlist, parse level info + const audioGroups: Array<AudioGroup> = levels.map(level => ({ + id: level.attrs.AUDIO, + codec: level.audioCodec + })); + + const audioTracks = M3U8Parser.parseMasterPlaylistMedia(string, url, 'AUDIO', audioGroups); + const subtitles = M3U8Parser.parseMasterPlaylistMedia(string, url, 'SUBTITLES'); + + if (audioTracks.length) { + // check if we have found an audio track embedded in main playlist (audio track without URI attribute) + let embeddedAudioFound = false; + audioTracks.forEach(audioTrack => { + if (!audioTrack.url) { + embeddedAudioFound = true; + } + }); + + // if no embedded audio track defined, but audio codec signaled in quality level, + // we need to signal this main audio track this could happen with playlists with + // alt audio rendition in which quality levels (main) + // contains both audio+video. but with mixed audio track not signaled + if (embeddedAudioFound === false && levels[0].audioCodec && !levels[0].attrs.AUDIO) { + logger.log('audio codec signaled in quality level, but no embedded audio track signaled, create one'); + audioTracks.unshift({ + type: 'main', + name: 'main', + default: false, + autoselect: false, + forced: false, + id: -1 + }); + } + } + + hls.trigger(Event.MANIFEST_LOADED, { + levels, + audioTracks, + subtitles, + url, + stats, + networkDetails + }); + } + + _handleTrackOrLevelPlaylist (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown) { + const hls = this.hls; + + const { id, level, type } = context; + + const url = PlaylistLoader.getResponseUrl(response, context); + + // if the values are null, they will result in the else conditional + const levelUrlId = Number.isFinite(id as number) ? id as number : 0; + const levelId = Number.isFinite(level as number) ? level as number : levelUrlId; + + const levelType = PlaylistLoader.mapContextToLevelType(context); + const levelDetails = M3U8Parser.parseLevelPlaylist(response.data as string, url, levelId, levelType, levelUrlId); + + // set stats on level structure + // TODO(jstackhouse): why? mixing concerns, is it just treated as value bag? + (levelDetails as any).tload = stats.tload; + + // We have done our first request (Manifest-type) and receive + // not a master playlist but a chunk-list (track/level) + // We fire the manifest-loaded event anyway with the parsed level-details + // by creating a single-level structure for it. + if (type === PlaylistContextType.MANIFEST) { + const singleLevel = { + url, + details: levelDetails + }; + + hls.trigger(Event.MANIFEST_LOADED, { + levels: [singleLevel], + audioTracks: [], + url, + stats, + networkDetails + }); + } + + // save parsing time + stats.tparsed = performance.now(); + + // in case we need SIDX ranges + // return early after calling load for + // the SIDX box. + if (levelDetails.needSidxRanges) { + const sidxUrl = levelDetails.initSegment.url; + this.load({ + url: sidxUrl, + isSidxRequest: true, + type, + level, + levelDetails, + id, + rangeStart: 0, + rangeEnd: 2048, + responseType: 'arraybuffer' + }); + return; + } + + // extend the context with the new levelDetails property + context.levelDetails = levelDetails; + + this._handlePlaylistLoaded(response, stats, context, networkDetails); + } + + _handleSidxRequest (response: LoaderResponse, context: PlaylistLoaderContext) { + if (typeof response.data === 'string') { + throw new Error('sidx request must be made with responseType of array buffer'); + } + + const sidxInfo = MP4Demuxer.parseSegmentIndex(new Uint8Array(response.data)); + // if provided fragment does not contain sidx, early return + if (!sidxInfo) { + return; + } + const sidxReferences = sidxInfo.references; + const levelDetails = context.levelDetails; + sidxReferences.forEach((segmentRef, index) => { + const segRefInfo = segmentRef.info; + if (!levelDetails) { + return; + } + const frag = levelDetails.fragments[index]; + if (frag.byteRange.length === 0) { + frag.setByteRange(String(1 + segRefInfo.end - segRefInfo.start) + '@' + String(segRefInfo.start)); + } + }); + + if (levelDetails) { + levelDetails.initSegment.setByteRange(String(sidxInfo.moovEndOffset) + '@0'); + } + } + + _handleManifestParsingError (response: LoaderResponse, context: PlaylistLoaderContext, reason: string, networkDetails: unknown) { + this.hls.trigger(Event.ERROR, { + type: ErrorTypes.NETWORK_ERROR, + details: ErrorDetails.MANIFEST_PARSING_ERROR, + fatal: true, + url: response.url, + reason, + networkDetails + }); + } + + _handleNetworkError (context: PlaylistLoaderContext, networkDetails: unknown, timeout: boolean = false, response: LoaderResponse | null = null) { + logger.info(`A network error occured while loading a ${context.type}-type playlist`); + + let details; + let fatal; + + const loader = this.getInternalLoader(context); + + switch (context.type) { + case PlaylistContextType.MANIFEST: + details = (timeout ? ErrorDetails.MANIFEST_LOAD_TIMEOUT : ErrorDetails.MANIFEST_LOAD_ERROR); + fatal = true; + break; + case PlaylistContextType.LEVEL: + details = (timeout ? ErrorDetails.LEVEL_LOAD_TIMEOUT : ErrorDetails.LEVEL_LOAD_ERROR); + fatal = false; + break; + case PlaylistContextType.AUDIO_TRACK: + details = (timeout ? ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT : ErrorDetails.AUDIO_TRACK_LOAD_ERROR); + fatal = false; + break; + default: + // details = ...? + fatal = false; + } + + if (loader) { + loader.abort(); + this.resetInternalLoader(context.type); + } + + // TODO(typescript-events): when error events are handled, type this + let errorData: any = { + type: ErrorTypes.NETWORK_ERROR, + details, + fatal, + url: context.url, + loader, + context, + networkDetails + }; + + if (response) { + errorData.response = response; + } + + this.hls.trigger(Event.ERROR, errorData); + } + + _handlePlaylistLoaded (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown) { + const { type, level, id, levelDetails } = context; + + if (!levelDetails || !levelDetails.targetduration) { + this._handleManifestParsingError(response, context, 'invalid target duration', networkDetails); + return; + } + + const canHaveLevels = PlaylistLoader.canHaveQualityLevels(context.type); + if (canHaveLevels) { + this.hls.trigger(Event.LEVEL_LOADED, { + details: levelDetails, + level: level || 0, + id: id || 0, + stats, + networkDetails + }); + } else { + switch (type) { + case PlaylistContextType.AUDIO_TRACK: + this.hls.trigger(Event.AUDIO_TRACK_LOADED, { + details: levelDetails, + id, + stats, + networkDetails + }); + break; + case PlaylistContextType.SUBTITLE_TRACK: + this.hls.trigger(Event.SUBTITLE_TRACK_LOADED, { + details: levelDetails, + id, + stats, + networkDetails + }); + break; + } + } + } +} + +export default PlaylistLoader; diff --git a/cmd/mjpeg-player/hlsjs/mts-demuxer.js b/cmd/mjpeg-player/hlsjs/mts-demuxer.js new file mode 100644 index 00000000..22ac59ca --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/mts-demuxer.js @@ -0,0 +1,383 @@ +/* +NAME + mts-demuxer.js + +AUTHOR + Trek Hopton <trek@ausocean.org> + +LICENSE + This file is Copyright (C) 2019 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. + + For hls.js Copyright notice and license, see LICENSE file. +*/ + +// MTSDemuxer demultiplexes an MPEG-TS stream into its individual streams. +// While it is possible that the MPEG-TS stream may contain many streams, +// this demuxer will result in at most one stream of each type ie. video, audio, id3 metadata. +class MTSDemuxer { + constructor() { + this.init(); + } + + // init initialises MTSDemuxer's state. It can be used to reset an MTSDemuxer instance. + init() { + this.pmtParsed = false; + this._pmtId = -1; + + this._videoTrack = MTSDemuxer.createTrack('video'); + this._audioTrack = MTSDemuxer.createTrack('audio'); + this._id3Track = MTSDemuxer.createTrack('id3'); + } + + // createTrack creates and returns a track model. + /** + * @param {string} type 'audio' | 'video' | 'id3' | 'text' + * @return {object} MTSDemuxer's internal track model. + */ + static createTrack(type) { + return { + type, + pid: -1, + data: [] // This will contain Uint8Arrays representing each PES packet's payload for this track. + }; + } + + // _getTracks returns this MTSDemuxer's tracks. + _getTracks() { + return { + video: this._videoTrack, + audio: this._audioTrack, + id3: this._id3Track + }; + } + + // _syncOffset scans the first 'maxScanWindow' bytes and returns an offset to the beginning of the first three MTS packets, + // or -1 if three are not found. + // A TS fragment should contain at least 3 TS packets, a PAT, a PMT, and one PID, each starting with 0x47. + static _syncOffset(data) { + const maxScanWindow = 1000; // 1000 is a reasonable number of bytes to search for the first MTS packets. + const scanWindow = Math.min(maxScanWindow, data.length - 3 * 188); + let i = 0; + while (i < scanWindow) { + if (data[i] === 0x47 && data[i + 188] === 0x47 && data[i + 2 * 188] === 0x47) { + return i; + } else { + i++; + } + } + return -1; + } + + append(data) { + let start, len = data.length, pusi, pid, afc, offset, pes, + unknownPIDs = false; + let pmtParsed = this.pmtParsed, + videoTrack = this._videoTrack, + audioTrack = this._audioTrack, + id3Track = this._id3Track, + videoId = videoTrack.pid, + audioId = audioTrack.pid, + id3Id = id3Track.pid, + pmtId = this._pmtId, + videoData = videoTrack.pesData, + audioData = audioTrack.pesData, + id3Data = id3Track.pesData, + parsePAT = this._parsePAT, + parsePMT = this._parsePMT, + parsePES = this._parsePES; + + const syncOffset = MTSDemuxer._syncOffset(data); + + // Don't parse last TS packet if incomplete. + len -= (len + syncOffset) % 188; + + // Loop through TS packets. + for (start = syncOffset; start < len; start += 188) { + if (data[start] === 0x47) { + pusi = !!(data[start + 1] & 0x40); + // pid is a 13-bit field starting at the last bit of TS[1]. + pid = ((data[start + 1] & 0x1f) << 8) + data[start + 2]; + afc = (data[start + 3] & 0x30) >> 4; + // If an adaption field is present, its length is specified by the fifth byte of the TS packet header. + if (afc > 1) { + offset = start + 5 + data[start + 4]; + // Continue if there is only adaptation field. + if (offset === (start + 188)) { + continue; + } + } else { + offset = start + 4; + } + switch (pid) { + case videoId: + if (pusi) { + if (videoData && (pes = parsePES(videoData)) && pes.pts !== undefined) { + videoTrack.data.push(pes.data); + // TODO: here pes contains data, pts, dts and len. Are all these needed? + } + videoData = { data: [], size: 0 }; + } + if (videoData) { + videoData.data.push(data.subarray(offset, start + 188)); + videoData.size += start + 188 - offset; + } + break; + case audioId: + if (pusi) { + if (audioData && (pes = parsePES(audioData)) && pes.pts !== undefined) { + audioTrack.data.push(pes.data); + } + audioData = { data: [], size: 0 }; + } + if (audioData) { + audioData.data.push(data.subarray(offset, start + 188)); + audioData.size += start + 188 - offset; + } + break; + case id3Id: + if (pusi) { + if (id3Data && (pes = parsePES(id3Data)) && pes.pts !== undefined) { + id3Track.data.push(pes.data); + } + id3Data = { data: [], size: 0 }; + } + if (id3Data) { + id3Data.data.push(data.subarray(offset, start + 188)); + id3Data.size += start + 188 - offset; + } + break; + case 0: + if (pusi) { + offset += data[offset] + 1; + } + + pmtId = this._pmtId = parsePAT(data, offset); + break; + case pmtId: + if (pusi) { + offset += data[offset] + 1; + } + + let parsedPIDs = parsePMT(data, offset); + + // Only update track id if track PID found while parsing PMT. + // This is to avoid resetting the PID to -1 in case track PID transiently disappears from the stream, + // this could happen in case of transient missing audio samples for example. + videoId = parsedPIDs.video; + if (videoId > 0) { + videoTrack.pid = videoId; + } + audioId = parsedPIDs.audio; + if (audioId > 0) { + audioTrack.pid = audioId; + } + id3Id = parsedPIDs.id3; + if (id3Id > 0) { + id3Track.pid = id3Id; + } + + if (unknownPIDs && !pmtParsed) { + // Reparse from beginning. + unknownPIDs = false; + // We set it to -188, the += 188 in the for loop will reset start to 0. + start = syncOffset - 188; + } + pmtParsed = this.pmtParsed = true; + break; + default: + unknownPIDs = true; + break; + } + } else { + console.error('TS packet did not start with 0x47'); + } + } + + // Try to parse last PES packets. + if (videoData && (pes = parsePES(videoData)) && pes.pts !== undefined) { + videoTrack.data.push(pes.data); + videoTrack.pesData = null; + } else { + // Either pesPkts null or PES truncated, keep it for next frag parsing. + videoTrack.pesData = videoData; + } + + if (audioData && (pes = parsePES(audioData)) && pes.pts !== undefined) { + audioTrack.data.push(pes.data); + audioTrack.pesData = null; + } else { + // Either pesPkts null or PES truncated, keep it for next frag parsing. + audioTrack.pesData = audioData; + } + + if (id3Data && (pes = parsePES(id3Data)) && pes.pts !== undefined) { + id3Track.data.push(pes.data); + id3Track.pesData = null; + } else { + // Either pesPkts null or PES truncated, keep it for next frag parsing. + id3Track.pesData = id3Data; + } + } + + _parsePAT(data, offset) { + // Skip the PSI header and parse the first PMT entry. + return (data[offset + 10] & 0x1F) << 8 | data[offset + 11]; + // console.log('PMT PID:' + this._pmtId); + } + + _parsePMT(data, offset) { + let programInfoLength, pid, result = { audio: -1, video: -1, id3: -1 }, + sectionLength = (data[offset + 1] & 0x0f) << 8 | data[offset + 2], + tableEnd = offset + 3 + sectionLength - 4; + // To determine where the table is, we have to figure out how + // long the program info descriptors are. + programInfoLength = (data[offset + 10] & 0x0f) << 8 | data[offset + 11]; + // Advance the offset to the first entry in the mapping table. + offset += 12 + programInfoLength; + while (offset < tableEnd) { + pid = (data[offset + 1] & 0x1F) << 8 | data[offset + 2]; + switch (data[offset]) { + case 0x1c: // MJPEG + case 0xdb: // SAMPLE-AES AVC. + case 0x1b: // ITU-T Rec. H.264 and ISO/IEC 14496-10 (lower bit-rate video). + if (result.video === -1) { + result.video = pid; + } + break; + case 0xcf: // SAMPLE-AES AAC. + case 0x0f: // ISO/IEC 13818-7 ADTS AAC (MPEG-2 lower bit-rate audio). + case 0xd2: // ADPCM audio. + case 0x03: // ISO/IEC 11172-3 (MPEG-1 audio). + case 0x24: + // console.warn('HEVC stream type found, not supported for now'); + case 0x04: // or ISO/IEC 13818-3 (MPEG-2 halved sample rate audio). + if (result.audio === -1) { + result.audio = pid; + } + break; + case 0x15: // Packetized metadata (ID3) + // console.log('ID3 PID:' + pid); + if (result.id3 === -1) { + result.id3 = pid; + } + break; + default: + // console.log('unknown stream type:' + data[offset]); + break; + } + // Move to the next table entry, skip past the elementary stream descriptors, if present. + offset += ((data[offset + 3] & 0x0F) << 8 | data[offset + 4]) + 5; + } + return result; + } + + _parsePES(stream) { + let i = 0, frag, pesFlags, pesPrefix, pesLen, pesHdrLen, pesData, pesPts, pesDts, payloadStartOffset, data = stream.data; + // Safety check. + if (!stream || stream.size === 0) { + return null; + } + + // We might need up to 19 bytes to read PES header. + // If first chunk of data is less than 19 bytes, let's merge it with following ones until we get 19 bytes. + // Usually only one merge is needed (and this is rare ...). + while (data[0].length < 19 && data.length > 1) { + let newData = new Uint8Array(data[0].length + data[1].length); + newData.set(data[0]); + newData.set(data[1], data[0].length); + data[0] = newData; + data.splice(1, 1); + } + // Retrieve PTS/DTS from first fragment. + frag = data[0]; + pesPrefix = (frag[0] << 16) + (frag[1] << 8) + frag[2]; + if (pesPrefix === 1) { + pesLen = (frag[4] << 8) + frag[5]; + // If PES parsed length is not zero and greater than total received length, stop parsing. PES might be truncated. + // Minus 6 : PES header size. + if (pesLen && pesLen > stream.size - 6) { + return null; + } + + pesFlags = frag[7]; + if (pesFlags & 0xC0) { + // PES header described here : http://dvd.sourceforge.net/dvdinfo/pes-hdr.html + // As PTS / DTS is 33 bit we cannot use bitwise operator in JS, + // as Bitwise operators treat their operands as a sequence of 32 bits. + pesPts = (frag[9] & 0x0E) * 536870912 +// 1 << 29 + (frag[10] & 0xFF) * 4194304 +// 1 << 22 + (frag[11] & 0xFE) * 16384 +// 1 << 14 + (frag[12] & 0xFF) * 128 +// 1 << 7 + (frag[13] & 0xFE) / 2; + // Check if greater than 2^32 -1. + if (pesPts > 4294967295) { + // Decrement 2^33. + pesPts -= 8589934592; + } + if (pesFlags & 0x40) { + pesDts = (frag[14] & 0x0E) * 536870912 +// 1 << 29 + (frag[15] & 0xFF) * 4194304 +// 1 << 22 + (frag[16] & 0xFE) * 16384 +// 1 << 14 + (frag[17] & 0xFF) * 128 +// 1 << 7 + (frag[18] & 0xFE) / 2; + // Check if greater than 2^32 -1. + if (pesDts > 4294967295) { + // Decrement 2^33. + pesDts -= 8589934592; + } + if (pesPts - pesDts > 60 * 90000) { + // console.warn(`${Math.round((pesPts - pesDts) / 90000)}s delta between PTS and DTS, align them`); + pesPts = pesDts; + } + } else { + pesDts = pesPts; + } + } + pesHdrLen = frag[8]; + // 9 bytes : 6 bytes for PES header + 3 bytes for PES extension. + payloadStartOffset = pesHdrLen + 9; + + stream.size -= payloadStartOffset; + // Reassemble PES packet. + pesData = new Uint8Array(stream.size); + for (let j = 0, dataLen = data.length; j < dataLen; j++) { + frag = data[j]; + let len = frag.byteLength; + if (payloadStartOffset) { + if (payloadStartOffset > len) { + // Trim full frag if PES header bigger than frag. + payloadStartOffset -= len; + continue; + } else { + // Trim partial frag if PES header smaller than frag. + frag = frag.subarray(payloadStartOffset); + len -= payloadStartOffset; + payloadStartOffset = 0; + } + } + pesData.set(frag, i); + i += len; + } + if (pesLen) { + // Payload size : remove PES header + PES extension. + pesLen -= pesHdrLen + 3; + } + return { data: pesData, pts: pesPts, dts: pesDts, len: pesLen }; + } else { + return null; + } + } +} \ No newline at end of file diff --git a/cmd/mjpeg-player/hlsjs/observer.js b/cmd/mjpeg-player/hlsjs/observer.js new file mode 100644 index 00000000..33d265eb --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/observer.js @@ -0,0 +1,15 @@ +import { EventEmitter } from 'eventemitter3'; + +/** + * Simple adapter sub-class of Nodejs-like EventEmitter. + */ +export class Observer extends EventEmitter { + /** + * We simply want to pass along the event-name itself + * in every call to a handler, which is the purpose of our `trigger` method + * extending the standard API. + */ + trigger (event: string, ...data: Array<any>): void { + this.emit(event, event, ...data); + } +} diff --git a/cmd/mjpeg-player/hlsjs/types/loader.js b/cmd/mjpeg-player/hlsjs/types/loader.js new file mode 100644 index 00000000..f99aa1da --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/types/loader.js @@ -0,0 +1,132 @@ +import Level from '../loader/level'; + +export interface LoaderContext { + // target URL + url: string + // loader response type (arraybuffer or default response type for playlist) + responseType: string + // start byte range offset + rangeStart?: number + // end byte range offset + rangeEnd?: number + // true if onProgress should report partial chunk of loaded content + progressData?: boolean +} + +export interface LoaderConfiguration { + // Max number of load retries + maxRetry: number + // Timeout after which `onTimeOut` callback will be triggered + // (if loading is still not finished after that delay) + timeout: number + // Delay between an I/O error and following connection retry (ms). + // This to avoid spamming the server + retryDelay: number + // max connection retry delay (ms) + maxRetryDelay: number +} + +export interface LoaderResponse { + url: string, + // TODO(jstackhouse): SharedArrayBuffer, es2017 extension to TS + data: string | ArrayBuffer +} + +export interface LoaderStats { + // performance.now() just after load() has been called + trequest: number + // performance.now() of first received byte + tfirst: number + // performance.now() on load complete + tload: number + // performance.now() on parse completion + tparsed: number + // number of loaded bytes + loaded: number + // total number of bytes + total: number +} + +type LoaderOnSuccess < T extends LoaderContext > = ( + response: LoaderResponse, + stats: LoaderStats, + context: T, + networkDetails: any +) => void; + +type LoaderOnProgress < T extends LoaderContext > = ( + stats: LoaderStats, + context: T, + data: string | ArrayBuffer, + networkDetails: any, +) => void; + +type LoaderOnError < T extends LoaderContext > = ( + error: { + // error status code + code: number, + // error description + text: string, + }, + context: T, + networkDetails: any, +) => void; + +type LoaderOnTimeout < T extends LoaderContext > = ( + stats: LoaderStats, + context: T, +) => void; + +export interface LoaderCallbacks<T extends LoaderContext>{ + onSuccess: LoaderOnSuccess<T>, + onError: LoaderOnError<T>, + onTimeout: LoaderOnTimeout<T>, + onProgress?: LoaderOnProgress<T>, +} + +export interface Loader<T extends LoaderContext> { + destroy(): void + abort(): void + load( + context: LoaderContext, + config: LoaderConfiguration, + callbacks: LoaderCallbacks<T>, + ): void + + context: T +} + +/** + * `type` property values for this loaders' context object + * @enum + * + */ +export enum PlaylistContextType { + MANIFEST = 'manifest', + LEVEL = 'level', + AUDIO_TRACK = 'audioTrack', + SUBTITLE_TRACK= 'subtitleTrack' +} + +/** + * @enum {string} + */ +export enum PlaylistLevelType { + MAIN = 'main', + AUDIO = 'audio', + SUBTITLE = 'subtitle' +} + +export interface PlaylistLoaderContext extends LoaderContext { + loader?: Loader<PlaylistLoaderContext> + + type: PlaylistContextType + // the level index to load + level: number | null + // TODO: what is id? + id: number | null + // defines if the loader is handling a sidx request for the playlist + isSidxRequest?: boolean + // internal reprsentation of a parsed m3u8 level playlist + levelDetails?: Level +} diff --git a/cmd/mjpeg-player/hlsjs/utils/codecs.js b/cmd/mjpeg-player/hlsjs/utils/codecs.js new file mode 100644 index 00000000..60ae8a94 --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/utils/codecs.js @@ -0,0 +1,77 @@ +// from http://mp4ra.org/codecs.html +const sampleEntryCodesISO = { + audio: { + 'a3ds': true, + 'ac-3': true, + 'ac-4': true, + 'alac': true, + 'alaw': true, + 'dra1': true, + 'dts+': true, + 'dts-': true, + 'dtsc': true, + 'dtse': true, + 'dtsh': true, + 'ec-3': true, + 'enca': true, + 'g719': true, + 'g726': true, + 'm4ae': true, + 'mha1': true, + 'mha2': true, + 'mhm1': true, + 'mhm2': true, + 'mlpa': true, + 'mp4a': true, + 'raw ': true, + 'Opus': true, + 'samr': true, + 'sawb': true, + 'sawp': true, + 'sevc': true, + 'sqcp': true, + 'ssmv': true, + 'twos': true, + 'ulaw': true + }, + video: { + 'avc1': true, + 'avc2': true, + 'avc3': true, + 'avc4': true, + 'avcp': true, + 'drac': true, + 'dvav': true, + 'dvhe': true, + 'encv': true, + 'hev1': true, + 'hvc1': true, + 'mjp2': true, + 'mp4v': true, + 'mvc1': true, + 'mvc2': true, + 'mvc3': true, + 'mvc4': true, + 'resv': true, + 'rv60': true, + 's263': true, + 'svc1': true, + 'svc2': true, + 'vc-1': true, + 'vp08': true, + 'vp09': true + } +}; + +export type CodecType = 'audio' | 'video'; + +function isCodecType (codec: string, type: CodecType): boolean { + const typeCodes = sampleEntryCodesISO[type]; + return !!typeCodes && typeCodes[codec.slice(0, 4)] === true; +} + +function isCodecSupportedInMp4 (codec: string, type: CodecType): boolean { + return MediaSource.isTypeSupported(`${type || 'video'}/mp4;codecs="${codec}"`); +} + +export { isCodecType, isCodecSupportedInMp4 }; diff --git a/cmd/mjpeg-player/hlsjs/utils/xhr-loader.js b/cmd/mjpeg-player/hlsjs/utils/xhr-loader.js new file mode 100644 index 00000000..10ff29c7 --- /dev/null +++ b/cmd/mjpeg-player/hlsjs/utils/xhr-loader.js @@ -0,0 +1,167 @@ +/** + * XHR based logger +*/ + +import { logger } from '../utils/logger'; + +const { performance, XMLHttpRequest } = window; + +class XhrLoader { + constructor (config) { + if (config && config.xhrSetup) { + this.xhrSetup = config.xhrSetup; + } + } + + destroy () { + this.abort(); + this.loader = null; + } + + abort () { + let loader = this.loader; + if (loader && loader.readyState !== 4) { + this.stats.aborted = true; + loader.abort(); + } + + window.clearTimeout(this.requestTimeout); + this.requestTimeout = null; + window.clearTimeout(this.retryTimeout); + this.retryTimeout = null; + } + + load (context, config, callbacks) { + this.context = context; + this.config = config; + this.callbacks = callbacks; + this.stats = { trequest: performance.now(), retry: 0 }; + this.retryDelay = config.retryDelay; + this.loadInternal(); + } + + loadInternal () { + let xhr, context = this.context; + xhr = this.loader = new XMLHttpRequest(); + + let stats = this.stats; + stats.tfirst = 0; + stats.loaded = 0; + const xhrSetup = this.xhrSetup; + + try { + if (xhrSetup) { + try { + xhrSetup(xhr, context.url); + } catch (e) { + // fix xhrSetup: (xhr, url) => {xhr.setRequestHeader("Content-Language", "test");} + // not working, as xhr.setRequestHeader expects xhr.readyState === OPEN + xhr.open('GET', context.url, true); + xhrSetup(xhr, context.url); + } + } + if (!xhr.readyState) { + xhr.open('GET', context.url, true); + } + } catch (e) { + // IE11 throws an exception on xhr.open if attempting to access an HTTP resource over HTTPS + this.callbacks.onError({ code: xhr.status, text: e.message }, context, xhr); + return; + } + + if (context.rangeEnd) { + xhr.setRequestHeader('Range', 'bytes=' + context.rangeStart + '-' + (context.rangeEnd - 1)); + } + + xhr.onreadystatechange = this.readystatechange.bind(this); + xhr.onprogress = this.loadprogress.bind(this); + xhr.responseType = context.responseType; + + // setup timeout before we perform request + this.requestTimeout = window.setTimeout(this.loadtimeout.bind(this), this.config.timeout); + xhr.send(); + } + + readystatechange (event) { + let xhr = event.currentTarget, + readyState = xhr.readyState, + stats = this.stats, + context = this.context, + config = this.config; + + // don't proceed if xhr has been aborted + if (stats.aborted) { + return; + } + + // >= HEADERS_RECEIVED + if (readyState >= 2) { + // clear xhr timeout and rearm it if readyState less than 4 + window.clearTimeout(this.requestTimeout); + if (stats.tfirst === 0) { + stats.tfirst = Math.max(performance.now(), stats.trequest); + } + + if (readyState === 4) { + let status = xhr.status; + // http status between 200 to 299 are all successful + if (status >= 200 && status < 300) { + stats.tload = Math.max(stats.tfirst, performance.now()); + let data, len; + if (context.responseType === 'arraybuffer') { + data = xhr.response; + len = data.byteLength; + } else { + data = xhr.responseText; + len = data.length; + } + stats.loaded = stats.total = len; + let response = { url: xhr.responseURL, data: data }; + this.callbacks.onSuccess(response, stats, context, xhr); + } else { + // if max nb of retries reached or if http status between 400 and 499 (such error cannot be recovered, retrying is useless), return error + if (stats.retry >= config.maxRetry || (status >= 400 && status < 499)) { + logger.error(`${status} while loading ${context.url}`); + this.callbacks.onError({ code: status, text: xhr.statusText }, context, xhr); + } else { + // retry + logger.warn(`${status} while loading ${context.url}, retrying in ${this.retryDelay}...`); + // aborts and resets internal state + this.destroy(); + // schedule retry + this.retryTimeout = window.setTimeout(this.loadInternal.bind(this), this.retryDelay); + // set exponential backoff + this.retryDelay = Math.min(2 * this.retryDelay, config.maxRetryDelay); + stats.retry++; + } + } + } else { + // readyState >= 2 AND readyState !==4 (readyState = HEADERS_RECEIVED || LOADING) rearm timeout as xhr not finished yet + this.requestTimeout = window.setTimeout(this.loadtimeout.bind(this), config.timeout); + } + } + } + + loadtimeout () { + logger.warn(`timeout while loading ${this.context.url}`); + this.callbacks.onTimeout(this.stats, this.context, null); + } + + loadprogress (event) { + let xhr = event.currentTarget, + stats = this.stats; + + stats.loaded = event.loaded; + if (event.lengthComputable) { + stats.total = event.total; + } + + let onProgress = this.callbacks.onProgress; + if (onProgress) { + // third arg is to provide on progress data + onProgress(stats, this.context, null, xhr); + } + } +} + +export default XhrLoader; diff --git a/cmd/mjpeg-player/index.html b/cmd/mjpeg-player/index.html new file mode 100644 index 00000000..064f436c --- /dev/null +++ b/cmd/mjpeg-player/index.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="utf-8"> + <title>Mjpeg Player + + + + + +
+
+
+
+ +
+
+
+ Frame Rate: fps +
+
+ +
+
+
+
+
+ ©2019 Australian Ocean Laboratory Limited (AusOcean) (License) +
+
+ + + \ No newline at end of file diff --git a/cmd/mjpeg-player/main.js b/cmd/mjpeg-player/main.js new file mode 100644 index 00000000..037b31ed --- /dev/null +++ b/cmd/mjpeg-player/main.js @@ -0,0 +1,75 @@ +/* +NAME + main.js + +AUTHOR + Trek Hopton + +LICENSE + This file is Copyright (C) 2019 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. +*/ + +// play will process and play the chosen target file. +function play() { + const viewer = document.getElementById('viewer'); + const input = event.target.files[0]; + const reader = new FileReader(); + + reader.onload = event => { + const player = new Worker("player.js"); + + let rate = document.getElementById('rate'); + if (rate.value && rate.value > 0) { + player.postMessage({ msg: "setFrameRate", data: rate.value }); + } + + player.onmessage = e => { + switch (e.data.msg) { + case "frame": + const blob = new Blob([new Uint8Array(e.data.data)], { + type: 'video/x-motion-jpeg' + }); + const url = URL.createObjectURL(blob); + viewer.src = url; + break; + case "log": + console.log(e.data.data); + break; + case "stop": + break; + default: + console.error("unknown message from player"); + break; + } + }; + + switch (input.name.split('.')[1]) { + case "mjpeg": + case "mjpg": + player.postMessage({ msg: "loadMjpeg", data: event.target.result }, [event.target.result]); + break; + case "ts": + player.postMessage({ msg: "loadMtsMjpeg", data: event.target.result }, [event.target.result]); + break; + default: + console.error("unknown file format"); + break; + } + }; + reader.onerror = error => reject(error); + reader.readAsArrayBuffer(input); + +} diff --git a/cmd/mjpeg-player/player.js b/cmd/mjpeg-player/player.js new file mode 100644 index 00000000..34ff75ab --- /dev/null +++ b/cmd/mjpeg-player/player.js @@ -0,0 +1,89 @@ +/* +NAME + player.js + +AUTHOR + Trek Hopton + +LICENSE + This file is Copyright (C) 2019 the Australian Ocean Lab (AusOcean) + + It is free software: you can redistribute it and/or modify them + under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + It is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + for more details. + + You should have received a copy of the GNU General Public License in gpl.txt. + If not, see http://www.gnu.org/licenses. +*/ + +let frameRate = 30; + +onmessage = e => { + switch (e.data.msg) { + case "setFrameRate": + frameRate = e.data.data; + break; + case "loadMjpeg": + self.importScripts('./lex-mjpeg.js'); + let mjpeg = new Uint8Array(e.data.data); + let lex = new MJPEGLexer(mjpeg); + player = new Player(lex); + player.setFrameRate(frameRate); + player.start(); + break; + case "loadMtsMjpeg": + self.importScripts('./hlsjs/mts-demuxer.js'); + let mtsMjpeg = new Uint8Array(e.data.data); + let demux = new MTSDemuxer(); + let tracks = demux._getTracks(); + demux.append(mtsMjpeg); + buf = new FrameBuffer(tracks.video.data); + player = new Player(buf); + player.setFrameRate(frameRate); //TODO: read frame rate from metadata. + player.start(); + break; + default: + console.error("unknown message from main thread"); + break; + } +}; + +class Player { + constructor(buffer) { + this.buffer = buffer; + this.frameRate = frameRate; + } + + setFrameRate(rate) { + this.frameRate = rate; + } + + start() { + let frame = this.buffer.read(); + if (frame != null) { + postMessage({ msg: "frame", data: frame.buffer }, [frame.buffer]); + setTimeout(() => { this.start(); }, 1000 / this.frameRate); + } else { + postMessage({ msg: "stop" }); + } + } +} + +// FrameBuffer allows an array of subarrays (MJPEG frames) to be read one at a time. +class FrameBuffer { + constructor(src) { + this.src = src; + this.off = 0; + } + + // read returns the next single frame. + read() { + return this.src[this.off++]; + } +} \ No newline at end of file diff --git a/cmd/mjpeg-player/url-toolkit/url-toolkit.js b/cmd/mjpeg-player/url-toolkit/url-toolkit.js new file mode 100644 index 00000000..44337aaa --- /dev/null +++ b/cmd/mjpeg-player/url-toolkit/url-toolkit.js @@ -0,0 +1,163 @@ +// see https://tools.ietf.org/html/rfc1808 + +/* jshint ignore:start */ +(function(root) { +/* jshint ignore:end */ + + var URL_REGEX = /^((?:[a-zA-Z0-9+\-.]+:)?)(\/\/[^\/?#]*)?((?:[^\/\?#]*\/)*.*?)??(;.*?)?(\?.*?)?(#.*?)?$/; + var FIRST_SEGMENT_REGEX = /^([^\/?#]*)(.*)$/; + var SLASH_DOT_REGEX = /(?:\/|^)\.(?=\/)/g; + var SLASH_DOT_DOT_REGEX = /(?:\/|^)\.\.\/(?!\.\.\/).*?(?=\/)/g; + + var URLToolkit = { // jshint ignore:line + // If opts.alwaysNormalize is true then the path will always be normalized even when it starts with / or // + // E.g + // With opts.alwaysNormalize = false (default, spec compliant) + // http://a.com/b/cd + /e/f/../g => http://a.com/e/f/../g + // With opts.alwaysNormalize = true (not spec compliant) + // http://a.com/b/cd + /e/f/../g => http://a.com/e/g + buildAbsoluteURL: function(baseURL, relativeURL, opts) { + opts = opts || {}; + // remove any remaining space and CRLF + baseURL = baseURL.trim(); + relativeURL = relativeURL.trim(); + if (!relativeURL) { + // 2a) If the embedded URL is entirely empty, it inherits the + // entire base URL (i.e., is set equal to the base URL) + // and we are done. + if (!opts.alwaysNormalize) { + return baseURL; + } + var basePartsForNormalise = URLToolkit.parseURL(baseURL); + if (!basePartsForNormalise) { + throw new Error('Error trying to parse base URL.'); + } + basePartsForNormalise.path = URLToolkit.normalizePath(basePartsForNormalise.path); + return URLToolkit.buildURLFromParts(basePartsForNormalise); + } + var relativeParts = URLToolkit.parseURL(relativeURL); + if (!relativeParts) { + throw new Error('Error trying to parse relative URL.'); + } + if (relativeParts.scheme) { + // 2b) If the embedded URL starts with a scheme name, it is + // interpreted as an absolute URL and we are done. + if (!opts.alwaysNormalize) { + return relativeURL; + } + relativeParts.path = URLToolkit.normalizePath(relativeParts.path); + return URLToolkit.buildURLFromParts(relativeParts); + } + var baseParts = URLToolkit.parseURL(baseURL); + if (!baseParts) { + throw new Error('Error trying to parse base URL.'); + } + if (!baseParts.netLoc && baseParts.path && baseParts.path[0] !== '/') { + // If netLoc missing and path doesn't start with '/', assume everthing before the first '/' is the netLoc + // This causes 'example.com/a' to be handled as '//example.com/a' instead of '/example.com/a' + var pathParts = FIRST_SEGMENT_REGEX.exec(baseParts.path); + baseParts.netLoc = pathParts[1]; + baseParts.path = pathParts[2]; + } + if (baseParts.netLoc && !baseParts.path) { + baseParts.path = '/'; + } + var builtParts = { + // 2c) Otherwise, the embedded URL inherits the scheme of + // the base URL. + scheme: baseParts.scheme, + netLoc: relativeParts.netLoc, + path: null, + params: relativeParts.params, + query: relativeParts.query, + fragment: relativeParts.fragment + }; + if (!relativeParts.netLoc) { + // 3) If the embedded URL's is non-empty, we skip to + // Step 7. Otherwise, the embedded URL inherits the + // (if any) of the base URL. + builtParts.netLoc = baseParts.netLoc; + // 4) If the embedded URL path is preceded by a slash "/", the + // path is not relative and we skip to Step 7. + if (relativeParts.path[0] !== '/') { + if (!relativeParts.path) { + // 5) If the embedded URL path is empty (and not preceded by a + // slash), then the embedded URL inherits the base URL path + builtParts.path = baseParts.path; + // 5a) if the embedded URL's is non-empty, we skip to + // step 7; otherwise, it inherits the of the base + // URL (if any) and + if (!relativeParts.params) { + builtParts.params = baseParts.params; + // 5b) if the embedded URL's is non-empty, we skip to + // step 7; otherwise, it inherits the of the base + // URL (if any) and we skip to step 7. + if (!relativeParts.query) { + builtParts.query = baseParts.query; + } + } + } else { + // 6) The last segment of the base URL's path (anything + // following the rightmost slash "/", or the entire path if no + // slash is present) is removed and the embedded URL's path is + // appended in its place. + var baseURLPath = baseParts.path; + var newPath = baseURLPath.substring(0, baseURLPath.lastIndexOf('/') + 1) + relativeParts.path; + builtParts.path = URLToolkit.normalizePath(newPath); + } + } + } + if (builtParts.path === null) { + builtParts.path = opts.alwaysNormalize ? URLToolkit.normalizePath(relativeParts.path) : relativeParts.path; + } + return URLToolkit.buildURLFromParts(builtParts); + }, + parseURL: function(url) { + var parts = URL_REGEX.exec(url); + if (!parts) { + return null; + } + return { + scheme: parts[1] || '', + netLoc: parts[2] || '', + path: parts[3] || '', + params: parts[4] || '', + query: parts[5] || '', + fragment: parts[6] || '' + }; + }, + normalizePath: function(path) { + // The following operations are + // then applied, in order, to the new path: + // 6a) All occurrences of "./", where "." is a complete path + // segment, are removed. + // 6b) If the path ends with "." as a complete path segment, + // that "." is removed. + path = path.split('').reverse().join('').replace(SLASH_DOT_REGEX, ''); + // 6c) All occurrences of "/../", where is a + // complete path segment not equal to "..", are removed. + // Removal of these path segments is performed iteratively, + // removing the leftmost matching pattern on each iteration, + // until no matching pattern remains. + // 6d) If the path ends with "/..", where is a + // complete path segment not equal to "..", that + // "/.." is removed. + while (path.length !== (path = path.replace(SLASH_DOT_DOT_REGEX, '')).length) {} // jshint ignore:line + return path.split('').reverse().join(''); + }, + buildURLFromParts: function(parts) { + return parts.scheme + parts.netLoc + parts.path + parts.params + parts.query + parts.fragment; + } + }; + +/* jshint ignore:start */ + if(typeof exports === 'object' && typeof module === 'object') + module.exports = URLToolkit; + else if(typeof define === 'function' && define.amd) + define([], function() { return URLToolkit; }); + else if(typeof exports === 'object') + exports["URLToolkit"] = URLToolkit; + else + root["URLToolkit"] = URLToolkit; +})(this); +/* jshint ignore:end */