Buckets:
ktongue/docker_container / simsite /frontend /node_modules /hls.js /src /controller /stream-controller.ts
| import BaseStreamController, { State } from './base-stream-controller'; | |
| import { findFragmentByPTS } from './fragment-finders'; | |
| import { FragmentState } from './fragment-tracker'; | |
| import { MAX_START_GAP_JUMP } from './gap-controller'; | |
| import TransmuxerInterface from '../demux/transmuxer-interface'; | |
| import { ErrorDetails } from '../errors'; | |
| import { Events } from '../events'; | |
| import { changeTypeSupported } from '../is-supported'; | |
| import { ElementaryStreamTypes, isMediaFragment } from '../loader/fragment'; | |
| import { PlaylistContextType, PlaylistLevelType } from '../types/loader'; | |
| import { ChunkMetadata } from '../types/transmuxer'; | |
| import { BufferHelper } from '../utils/buffer-helper'; | |
| import { pickMostCompleteCodecName } from '../utils/codecs'; | |
| import { | |
| addEventListener, | |
| removeEventListener, | |
| } from '../utils/event-listener-helper'; | |
| import { useAlternateAudio } from '../utils/rendition-helper'; | |
| import type { FragmentTracker } from './fragment-tracker'; | |
| import type Hls from '../hls'; | |
| import type { Fragment, MediaFragment } from '../loader/fragment'; | |
| import type KeyLoader from '../loader/key-loader'; | |
| import type { LevelDetails } from '../loader/level-details'; | |
| import type { | |
| BufferCreatedTrack, | |
| ExtendedSourceBuffer, | |
| SourceBufferName, | |
| } from '../types/buffer'; | |
| import type { NetworkComponentAPI } from '../types/component-api'; | |
| import type { | |
| AudioTrackSwitchedData, | |
| AudioTrackSwitchingData, | |
| BufferCodecsData, | |
| BufferCreatedData, | |
| BufferEOSData, | |
| BufferFlushedData, | |
| ErrorData, | |
| FragBufferedData, | |
| FragLoadedData, | |
| FragParsingMetadataData, | |
| FragParsingUserdataData, | |
| LevelLoadedData, | |
| LevelLoadingData, | |
| LevelsUpdatedData, | |
| ManifestParsedData, | |
| MediaAttachedData, | |
| MediaDetachingData, | |
| } from '../types/events'; | |
| import type { Level } from '../types/level'; | |
| import type { Track, TrackSet } from '../types/track'; | |
| import type { TransmuxerResult } from '../types/transmuxer'; | |
| import type { BufferInfo } from '../utils/buffer-helper'; | |
| const TICK_INTERVAL = 100; // how often to tick in ms | |
| const enum AlternateAudio { | |
| DISABLED = 0, | |
| SWITCHING, | |
| SWITCHED, | |
| } | |
| export default class StreamController | |
| extends BaseStreamController | |
| implements NetworkComponentAPI | |
| { | |
| private audioCodecSwap: boolean = false; | |
| private level: number = -1; | |
| private _forceStartLoad: boolean = false; | |
| private _hasEnoughToStart: boolean = false; | |
| private altAudio: AlternateAudio = AlternateAudio.DISABLED; | |
| private audioOnly: boolean = false; | |
| private fragPlaying: Fragment | null = null; | |
| private fragLastKbps: number = 0; | |
| private couldBacktrack: boolean = false; | |
| private backtrackFragment: Fragment | null = null; | |
| private audioCodecSwitch: boolean = false; | |
| private videoBuffer: ExtendedSourceBuffer | null = null; | |
| constructor( | |
| hls: Hls, | |
| fragmentTracker: FragmentTracker, | |
| keyLoader: KeyLoader, | |
| ) { | |
| super( | |
| hls, | |
| fragmentTracker, | |
| keyLoader, | |
| 'stream-controller', | |
| PlaylistLevelType.MAIN, | |
| ); | |
| this.registerListeners(); | |
| } | |
| protected registerListeners() { | |
| super.registerListeners(); | |
| const { hls } = this; | |
| hls.on(Events.MANIFEST_PARSED, this.onManifestParsed, this); | |
| hls.on(Events.LEVEL_LOADING, this.onLevelLoading, this); | |
| hls.on(Events.LEVEL_LOADED, this.onLevelLoaded, this); | |
| hls.on( | |
| Events.FRAG_LOAD_EMERGENCY_ABORTED, | |
| this.onFragLoadEmergencyAborted, | |
| this, | |
| ); | |
| hls.on(Events.AUDIO_TRACK_SWITCHING, this.onAudioTrackSwitching, this); | |
| hls.on(Events.AUDIO_TRACK_SWITCHED, this.onAudioTrackSwitched, this); | |
| hls.on(Events.BUFFER_CREATED, this.onBufferCreated, this); | |
| hls.on(Events.BUFFER_FLUSHED, this.onBufferFlushed, this); | |
| hls.on(Events.LEVELS_UPDATED, this.onLevelsUpdated, this); | |
| hls.on(Events.FRAG_BUFFERED, this.onFragBuffered, this); | |
| } | |
| protected unregisterListeners() { | |
| super.unregisterListeners(); | |
| const { hls } = this; | |
| hls.off(Events.MANIFEST_PARSED, this.onManifestParsed, this); | |
| hls.off(Events.LEVEL_LOADED, this.onLevelLoaded, this); | |
| hls.off( | |
| Events.FRAG_LOAD_EMERGENCY_ABORTED, | |
| this.onFragLoadEmergencyAborted, | |
| this, | |
| ); | |
| hls.off(Events.AUDIO_TRACK_SWITCHING, this.onAudioTrackSwitching, this); | |
| hls.off(Events.AUDIO_TRACK_SWITCHED, this.onAudioTrackSwitched, this); | |
| hls.off(Events.BUFFER_CREATED, this.onBufferCreated, this); | |
| hls.off(Events.BUFFER_FLUSHED, this.onBufferFlushed, this); | |
| hls.off(Events.LEVELS_UPDATED, this.onLevelsUpdated, this); | |
| hls.off(Events.FRAG_BUFFERED, this.onFragBuffered, this); | |
| } | |
| protected onHandlerDestroying() { | |
| // @ts-ignore | |
| this.onMediaPlaying = this.onMediaSeeked = null; | |
| this.unregisterListeners(); | |
| super.onHandlerDestroying(); | |
| } | |
| public startLoad( | |
| startPosition: number, | |
| skipSeekToStartPosition?: boolean, | |
| ): void { | |
| if (this.levels) { | |
| const { lastCurrentTime, hls } = this; | |
| this.stopLoad(); | |
| this.setInterval(TICK_INTERVAL); | |
| this.level = -1; | |
| if (!this.startFragRequested) { | |
| // determine load level | |
| let startLevel = hls.startLevel; | |
| if (startLevel === -1) { | |
| if (hls.config.testBandwidth && this.levels.length > 1) { | |
| // -1 : guess start Level by doing a bitrate test by loading first fragment of lowest quality level | |
| startLevel = 0; | |
| this.bitrateTest = true; | |
| } else { | |
| startLevel = hls.firstAutoLevel; | |
| } | |
| } | |
| // 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 | |
| hls.nextLoadLevel = startLevel; | |
| this.level = hls.loadLevel; | |
| this._hasEnoughToStart = !!skipSeekToStartPosition; | |
| } | |
| // if startPosition undefined but lastCurrentTime set, set startPosition to last currentTime | |
| if ( | |
| lastCurrentTime > 0 && | |
| startPosition === -1 && | |
| !skipSeekToStartPosition | |
| ) { | |
| this.log( | |
| `Override startPosition with lastCurrentTime @${lastCurrentTime.toFixed( | |
| 3, | |
| )}`, | |
| ); | |
| startPosition = lastCurrentTime; | |
| } | |
| this.state = State.IDLE; | |
| this.nextLoadPosition = this.lastCurrentTime = | |
| startPosition + this.timelineOffset; | |
| this.startPosition = skipSeekToStartPosition ? -1 : startPosition; | |
| this.tick(); | |
| } else { | |
| this._forceStartLoad = true; | |
| this.state = State.STOPPED; | |
| } | |
| } | |
| public stopLoad() { | |
| this._forceStartLoad = false; | |
| super.stopLoad(); | |
| } | |
| protected doTick() { | |
| switch (this.state) { | |
| case State.WAITING_LEVEL: { | |
| const { levels, level } = this; | |
| const currentLevel = levels?.[level]; | |
| const details = currentLevel?.details; | |
| if ( | |
| details && | |
| (!details.live || | |
| (this.levelLastLoaded === currentLevel && | |
| !this.waitForLive(currentLevel))) | |
| ) { | |
| if (this.waitForCdnTuneIn(details)) { | |
| break; | |
| } | |
| this.state = State.IDLE; | |
| break; | |
| } else if (this.hls.nextLoadLevel !== this.level) { | |
| this.state = State.IDLE; | |
| break; | |
| } | |
| break; | |
| } | |
| case State.FRAG_LOADING_WAITING_RETRY: | |
| this.checkRetryDate(); | |
| break; | |
| default: | |
| break; | |
| } | |
| if (this.state === State.IDLE) { | |
| this.doTickIdle(); | |
| } | |
| this.onTickEnd(); | |
| } | |
| protected onTickEnd() { | |
| super.onTickEnd(); | |
| if (this.media?.readyState && this.media.seeking === false) { | |
| this.lastCurrentTime = this.media.currentTime; | |
| } | |
| this.checkFragmentChanged(); | |
| } | |
| private doTickIdle() { | |
| const { hls, levelLastLoaded, levels, media } = this; | |
| // if start level not parsed yet OR | |
| // if video not attached AND start fragment already requested OR start frag prefetch not enabled | |
| // exit loop, as we either need more info (level not parsed) or we need media to be attached to load new fragment | |
| if ( | |
| levelLastLoaded === null || | |
| (!media && | |
| !this.primaryPrefetch && | |
| (this.startFragRequested || !hls.config.startFragPrefetch)) | |
| ) { | |
| return; | |
| } | |
| // If the "main" level is audio-only but we are loading an alternate track in the same group, do not load anything | |
| if (this.altAudio && this.audioOnly) { | |
| return; | |
| } | |
| const level = this.buffering ? hls.nextLoadLevel : hls.loadLevel; | |
| if (!levels?.[level]) { | |
| return; | |
| } | |
| const levelInfo = levels[level]; | |
| // if buffer length is less than maxBufLen try to load a new fragment | |
| const bufferInfo = this.getMainFwdBufferInfo(); | |
| if (bufferInfo === null) { | |
| return; | |
| } | |
| const lastDetails = this.getLevelDetails(); | |
| if (lastDetails && this._streamEnded(bufferInfo, lastDetails)) { | |
| const data: BufferEOSData = {}; | |
| if (this.altAudio === AlternateAudio.SWITCHED) { | |
| data.type = 'video'; | |
| } | |
| this.hls.trigger(Events.BUFFER_EOS, data); | |
| this.state = State.ENDED; | |
| return; | |
| } | |
| if (!this.buffering) { | |
| return; | |
| } | |
| // set next load level : this will trigger a playlist load if needed | |
| if (hls.loadLevel !== level && hls.manualLevel === -1) { | |
| this.log(`Adapting to level ${level} from level ${this.level}`); | |
| } | |
| 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 || | |
| this.state === State.WAITING_LEVEL || | |
| this.waitForLive(levelInfo) | |
| ) { | |
| this.level = level; | |
| this.state = State.WAITING_LEVEL; | |
| this.startFragRequested = false; | |
| return; | |
| } | |
| const bufferLen = bufferInfo.len; | |
| // compute max Buffer Length that we could get from this load level, based on level bitrate. don't buffer more than 60 MB and more than 30s | |
| const maxBufLen = this.getMaxBufferLength(levelInfo.maxBitrate); | |
| // Stay idle if we are still with buffer margins | |
| if (bufferLen >= maxBufLen) { | |
| return; | |
| } | |
| if ( | |
| this.backtrackFragment && | |
| this.backtrackFragment.start > bufferInfo.end | |
| ) { | |
| this.backtrackFragment = null; | |
| } | |
| const targetBufferTime = this.backtrackFragment | |
| ? this.backtrackFragment.start | |
| : bufferInfo.end; | |
| let frag = this.getNextFragment(targetBufferTime, levelDetails); | |
| // Avoid backtracking by loading an earlier segment in streams with segments that do not start with a key frame (flagged by `couldBacktrack`) | |
| if ( | |
| this.couldBacktrack && | |
| !this.fragPrevious && | |
| frag && | |
| isMediaFragment(frag) && | |
| this.fragmentTracker.getState(frag) !== FragmentState.OK | |
| ) { | |
| const backtrackSn = (this.backtrackFragment ?? frag).sn as number; | |
| const fragIdx = backtrackSn - levelDetails.startSN; | |
| const backtrackFrag = levelDetails.fragments[fragIdx - 1]; | |
| if ((backtrackFrag as any) && frag.cc === backtrackFrag.cc) { | |
| frag = backtrackFrag; | |
| this.fragmentTracker.removeFragment(backtrackFrag); | |
| } | |
| } else if (this.backtrackFragment && bufferInfo.len) { | |
| this.backtrackFragment = null; | |
| } | |
| // Avoid loop loading by using nextLoadPosition set for backtracking and skipping consecutive GAP tags | |
| if (frag && this.isLoopLoading(frag, targetBufferTime)) { | |
| const gapStart = frag.gap; | |
| if (!gapStart) { | |
| // Cleanup the fragment tracker before trying to find the next unbuffered fragment | |
| const type = | |
| this.audioOnly && !this.altAudio | |
| ? ElementaryStreamTypes.AUDIO | |
| : ElementaryStreamTypes.VIDEO; | |
| const mediaBuffer = | |
| (type === ElementaryStreamTypes.VIDEO | |
| ? this.videoBuffer | |
| : this.mediaBuffer) || this.media; | |
| if (mediaBuffer) { | |
| this.afterBufferFlushed(mediaBuffer, type, PlaylistLevelType.MAIN); | |
| } | |
| } | |
| frag = this.getNextFragmentLoopLoading( | |
| frag, | |
| levelDetails, | |
| bufferInfo, | |
| PlaylistLevelType.MAIN, | |
| maxBufLen, | |
| ); | |
| } | |
| if (!frag) { | |
| return; | |
| } | |
| if (frag.initSegment && !frag.initSegment.data && !this.bitrateTest) { | |
| frag = frag.initSegment; | |
| } | |
| this.loadFragment(frag, levelInfo, targetBufferTime); | |
| } | |
| protected loadFragment( | |
| frag: Fragment, | |
| level: Level, | |
| targetBufferTime: number, | |
| ) { | |
| // Check if fragment is not loaded | |
| const fragState = this.fragmentTracker.getState(frag); | |
| if ( | |
| fragState === FragmentState.NOT_LOADED || | |
| fragState === FragmentState.PARTIAL | |
| ) { | |
| if (!isMediaFragment(frag)) { | |
| this._loadInitSegment(frag, level); | |
| } else if (this.bitrateTest) { | |
| this.log( | |
| `Fragment ${frag.sn} of level ${frag.level} is being downloaded to test bitrate and will not be buffered`, | |
| ); | |
| this._loadBitrateTestFrag(frag, level); | |
| } else { | |
| super.loadFragment(frag, level, targetBufferTime); | |
| } | |
| } else { | |
| this.clearTrackerIfNeeded(frag); | |
| } | |
| } | |
| private getBufferedFrag(position: number) { | |
| return this.fragmentTracker.getBufferedFrag( | |
| position, | |
| PlaylistLevelType.MAIN, | |
| ); | |
| } | |
| private followingBufferedFrag(frag: Fragment | null) { | |
| if (frag) { | |
| // try to get range of next fragment (500ms after this range) | |
| return this.getBufferedFrag(frag.end + 0.5); | |
| } | |
| return null; | |
| } | |
| /* | |
| on immediate level switch : | |
| - pause playback if playing | |
| - cancel any pending load request | |
| - and trigger a buffer flush | |
| */ | |
| public immediateLevelSwitch() { | |
| this.abortCurrentFrag(); | |
| this.flushMainBuffer(0, Number.POSITIVE_INFINITY); | |
| } | |
| /** | |
| * 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 | |
| */ | |
| public nextLevelSwitch() { | |
| const { levels, media } = this; | |
| // ensure that media is defined and that metadata are available (to retrieve currentTime) | |
| if (media?.readyState) { | |
| let fetchdelay; | |
| const fragPlayingCurrent = this.getAppendedFrag(media.currentTime); | |
| if (fragPlayingCurrent && fragPlayingCurrent.start > 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.start - 1); | |
| } | |
| const levelDetails = this.getLevelDetails(); | |
| if (levelDetails?.live) { | |
| const bufferInfo = this.getMainFwdBufferInfo(); | |
| // Do not flush in live stream with low buffer | |
| if (!bufferInfo || bufferInfo.len < levelDetails.targetduration * 2) { | |
| return; | |
| } | |
| } | |
| if (!media.paused && levels) { | |
| // add a safety delay of 1s | |
| const nextLevelId = this.hls.nextLoadLevel; | |
| const nextLevel = levels[nextLevelId]; | |
| const fragLastKbps = this.fragLastKbps; | |
| if (fragLastKbps && this.fragCurrent) { | |
| fetchdelay = | |
| (this.fragCurrent.duration * nextLevel.maxBitrate) / | |
| (1000 * fragLastKbps) + | |
| 1; | |
| } else { | |
| fetchdelay = 0; | |
| } | |
| } else { | |
| fetchdelay = 0; | |
| } | |
| // this.log('fetchdelay:'+fetchdelay); | |
| // find buffer range that will be reached once new fragment will be fetched | |
| const bufferedFrag = this.getBufferedFrag(media.currentTime + fetchdelay); | |
| if (bufferedFrag) { | |
| // we can flush buffer range following this one without stalling playback | |
| const nextBufferedFrag = this.followingBufferedFrag(bufferedFrag); | |
| if (nextBufferedFrag) { | |
| // if we are here, we can also cancel any loading/demuxing in progress, as they are useless | |
| this.abortCurrentFrag(); | |
| // start flush position is in next buffered frag. Leave some padding for non-independent segments and smoother playback. | |
| const maxStart = nextBufferedFrag.maxStartPTS | |
| ? nextBufferedFrag.maxStartPTS | |
| : nextBufferedFrag.start; | |
| const fragDuration = nextBufferedFrag.duration; | |
| const startPts = Math.max( | |
| bufferedFrag.end, | |
| maxStart + | |
| Math.min( | |
| Math.max( | |
| fragDuration - this.config.maxFragLookUpTolerance, | |
| fragDuration * (this.couldBacktrack ? 0.5 : 0.125), | |
| ), | |
| fragDuration * (this.couldBacktrack ? 0.75 : 0.25), | |
| ), | |
| ); | |
| this.flushMainBuffer(startPts, Number.POSITIVE_INFINITY); | |
| } | |
| } | |
| } | |
| } | |
| private abortCurrentFrag() { | |
| const fragCurrent = this.fragCurrent; | |
| this.fragCurrent = null; | |
| this.backtrackFragment = null; | |
| if (fragCurrent) { | |
| fragCurrent.abortRequests(); | |
| this.fragmentTracker.removeFragment(fragCurrent); | |
| } | |
| switch (this.state) { | |
| case State.KEY_LOADING: | |
| case State.FRAG_LOADING: | |
| case State.FRAG_LOADING_WAITING_RETRY: | |
| case State.PARSING: | |
| case State.PARSED: | |
| this.state = State.IDLE; | |
| break; | |
| } | |
| this.nextLoadPosition = this.getLoadPosition(); | |
| } | |
| protected flushMainBuffer(startOffset: number, endOffset: number) { | |
| super.flushMainBuffer( | |
| startOffset, | |
| endOffset, | |
| this.altAudio === AlternateAudio.SWITCHED ? 'video' : null, | |
| ); | |
| } | |
| protected onMediaAttached( | |
| event: Events.MEDIA_ATTACHED, | |
| data: MediaAttachedData, | |
| ) { | |
| super.onMediaAttached(event, data); | |
| const media = data.media; | |
| addEventListener(media, 'playing', this.onMediaPlaying); | |
| addEventListener(media, 'seeked', this.onMediaSeeked); | |
| } | |
| protected onMediaDetaching( | |
| event: Events.MEDIA_DETACHING, | |
| data: MediaDetachingData, | |
| ) { | |
| const { media } = this; | |
| if (media) { | |
| removeEventListener(media, 'playing', this.onMediaPlaying); | |
| removeEventListener(media, 'seeked', this.onMediaSeeked); | |
| } | |
| this.videoBuffer = null; | |
| this.fragPlaying = null; | |
| super.onMediaDetaching(event, data); | |
| const transferringMedia = !!data.transferMedia; | |
| if (transferringMedia) { | |
| return; | |
| } | |
| this._hasEnoughToStart = false; | |
| } | |
| private onMediaPlaying = () => { | |
| // tick to speed up FRAG_CHANGED triggering | |
| this.tick(); | |
| }; | |
| private onMediaSeeked = () => { | |
| const media = this.media; | |
| const currentTime = media ? media.currentTime : null; | |
| if (currentTime === null || !Number.isFinite(currentTime)) { | |
| return; | |
| } | |
| this.log(`Media seeked to ${currentTime.toFixed(3)}`); | |
| // If seeked was issued before buffer was appended do not tick immediately | |
| if (!this.getBufferedFrag(currentTime)) { | |
| return; | |
| } | |
| const bufferInfo = this.getFwdBufferInfoAtPos( | |
| media, | |
| currentTime, | |
| PlaylistLevelType.MAIN, | |
| 0, | |
| ); | |
| if (bufferInfo === null || bufferInfo.len === 0) { | |
| this.warn( | |
| `Main forward buffer length at ${currentTime} on "seeked" event ${ | |
| bufferInfo ? bufferInfo.len : 'empty' | |
| })`, | |
| ); | |
| return; | |
| } | |
| // tick to speed up FRAG_CHANGED triggering | |
| this.tick(); | |
| }; | |
| protected onManifestLoading() { | |
| super.onManifestLoading(); | |
| // reset buffer on manifest loading | |
| this.log('Trigger BUFFER_RESET'); | |
| this.hls.trigger(Events.BUFFER_RESET, undefined); | |
| this.couldBacktrack = false; | |
| this.fragLastKbps = 0; | |
| this.fragPlaying = this.backtrackFragment = null; | |
| this.altAudio = AlternateAudio.DISABLED; | |
| this.audioOnly = false; | |
| } | |
| private onManifestParsed( | |
| event: Events.MANIFEST_PARSED, | |
| data: ManifestParsedData, | |
| ) { | |
| // detect if we have different kind of audio codecs used amongst playlists | |
| let aac = false; | |
| let heaac = false; | |
| for (let i = 0; i < data.levels.length; i++) { | |
| const codec = data.levels[i].audioCodec; | |
| if (codec) { | |
| aac = aac || codec.indexOf('mp4a.40.2') !== -1; | |
| heaac = heaac || codec.indexOf('mp4a.40.5') !== -1; | |
| } | |
| } | |
| this.audioCodecSwitch = aac && heaac && !changeTypeSupported(); | |
| if (this.audioCodecSwitch) { | |
| this.log( | |
| 'Both AAC/HE-AAC audio found in levels; declaring level codec as HE-AAC', | |
| ); | |
| } | |
| this.levels = data.levels; | |
| this.startFragRequested = false; | |
| } | |
| private onLevelLoading(event: Events.LEVEL_LOADING, data: LevelLoadingData) { | |
| const { levels } = this; | |
| if (!levels || this.state !== State.IDLE) { | |
| return; | |
| } | |
| const level = data.levelInfo; | |
| if ( | |
| !level.details || | |
| (level.details.live && | |
| (this.levelLastLoaded !== level || level.details.expired)) || | |
| this.waitForCdnTuneIn(level.details) | |
| ) { | |
| this.state = State.WAITING_LEVEL; | |
| } | |
| } | |
| private onLevelLoaded(event: Events.LEVEL_LOADED, data: LevelLoadedData) { | |
| const { levels, startFragRequested } = this; | |
| const newLevelId = data.level; | |
| const newDetails = data.details; | |
| const duration = newDetails.totalduration; | |
| if (!levels) { | |
| this.warn(`Levels were reset while loading level ${newLevelId}`); | |
| return; | |
| } | |
| this.log( | |
| `Level ${newLevelId} loaded [${newDetails.startSN},${newDetails.endSN}]${ | |
| newDetails.lastPartSn | |
| ? `[part-${newDetails.lastPartSn}-${newDetails.lastPartIndex}]` | |
| : '' | |
| }, cc [${newDetails.startCC}, ${newDetails.endCC}] duration:${duration}`, | |
| ); | |
| const curLevel = data.levelInfo; | |
| const fragCurrent = this.fragCurrent; | |
| if ( | |
| fragCurrent && | |
| (this.state === State.FRAG_LOADING || | |
| this.state === State.FRAG_LOADING_WAITING_RETRY) | |
| ) { | |
| if (fragCurrent.level !== data.level && fragCurrent.loader) { | |
| this.abortCurrentFrag(); | |
| } | |
| } | |
| let sliding = 0; | |
| if (newDetails.live || curLevel.details?.live) { | |
| this.checkLiveUpdate(newDetails); | |
| if (newDetails.deltaUpdateFailed) { | |
| return; | |
| } | |
| sliding = this.alignPlaylists( | |
| newDetails, | |
| curLevel.details, | |
| this.levelLastLoaded?.details, | |
| ); | |
| } | |
| // override level info | |
| curLevel.details = newDetails; | |
| this.levelLastLoaded = curLevel; | |
| if (!startFragRequested) { | |
| this.setStartPosition(newDetails, sliding); | |
| } | |
| this.hls.trigger(Events.LEVEL_UPDATED, { | |
| details: newDetails, | |
| level: newLevelId, | |
| }); | |
| // only switch back to IDLE state if we were waiting for level to start downloading a new fragment | |
| if (this.state === State.WAITING_LEVEL) { | |
| if (this.waitForCdnTuneIn(newDetails)) { | |
| // Wait for Low-Latency CDN Tune-in | |
| return; | |
| } | |
| this.state = State.IDLE; | |
| } | |
| if (startFragRequested && newDetails.live) { | |
| this.synchronizeToLiveEdge(newDetails); | |
| } | |
| // trigger handler right now | |
| this.tick(); | |
| } | |
| private synchronizeToLiveEdge(levelDetails: LevelDetails) { | |
| const { config, media } = this; | |
| if (!media) { | |
| return; | |
| } | |
| const liveSyncPosition = this.hls.liveSyncPosition; | |
| const currentTime = this.getLoadPosition(); | |
| const start = levelDetails.fragmentStart; | |
| const end = levelDetails.edge; | |
| const withinSlidingWindow = | |
| currentTime >= start - config.maxFragLookUpTolerance && | |
| currentTime <= end; | |
| // Continue if we can seek forward to sync position or if current time is outside of sliding window | |
| if ( | |
| liveSyncPosition !== null && | |
| media.duration > liveSyncPosition && | |
| (currentTime < liveSyncPosition || !withinSlidingWindow) | |
| ) { | |
| // Continue if buffer is starving or if current time is behind max latency | |
| const maxLatency = | |
| config.liveMaxLatencyDuration !== undefined | |
| ? config.liveMaxLatencyDuration | |
| : config.liveMaxLatencyDurationCount * levelDetails.targetduration; | |
| if ( | |
| (!withinSlidingWindow && media.readyState < 4) || | |
| currentTime < end - maxLatency | |
| ) { | |
| if (!this._hasEnoughToStart) { | |
| this.nextLoadPosition = liveSyncPosition; | |
| } | |
| // Only seek if ready and there is not a significant forward buffer available for playback | |
| if (media.readyState) { | |
| this.warn( | |
| `Playback: ${currentTime.toFixed( | |
| 3, | |
| )} is located too far from the end of live sliding playlist: ${end}, reset currentTime to : ${liveSyncPosition.toFixed( | |
| 3, | |
| )}`, | |
| ); | |
| if (this.config.liveSyncMode === 'buffered') { | |
| const bufferInfo = BufferHelper.bufferInfo( | |
| media, | |
| liveSyncPosition, | |
| 0, | |
| ); | |
| if (!bufferInfo.buffered?.length) { | |
| media.currentTime = liveSyncPosition; | |
| return; | |
| } | |
| const isLiveSyncInBuffer = bufferInfo.start <= currentTime; | |
| if (isLiveSyncInBuffer) { | |
| media.currentTime = liveSyncPosition; | |
| return; | |
| } | |
| const { nextStart } = BufferHelper.bufferedInfo( | |
| bufferInfo.buffered, | |
| currentTime, | |
| 0, | |
| ); | |
| if (nextStart) { | |
| media.currentTime = nextStart; | |
| } | |
| } else { | |
| media.currentTime = liveSyncPosition; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| protected _handleFragmentLoadProgress(data: FragLoadedData) { | |
| const frag = data.frag as MediaFragment; | |
| const { part, payload } = data; | |
| const { levels } = this; | |
| if (!levels) { | |
| this.warn( | |
| `Levels were reset while fragment load was in progress. Fragment ${frag.sn} of level ${frag.level} will not be buffered`, | |
| ); | |
| return; | |
| } | |
| const currentLevel = levels[frag.level]; | |
| if (!currentLevel as any) { | |
| this.warn(`Level ${frag.level} not found on progress`); | |
| return; | |
| } | |
| const details = currentLevel.details; | |
| if (!details) { | |
| this.warn( | |
| `Dropping fragment ${frag.sn} of level ${frag.level} after level details were reset`, | |
| ); | |
| this.fragmentTracker.removeFragment(frag); | |
| return; | |
| } | |
| const videoCodec = currentLevel.videoCodec; | |
| // time Offset is accurate if level PTS is known, or if playlist is not sliding (not live) | |
| const accurateTimeOffset = details.PTSKnown || !details.live; | |
| const initSegmentData = frag.initSegment?.data; | |
| const audioCodec = this._getAudioCodec(currentLevel); | |
| // transmux the MPEG-TS data to ISO-BMFF segments | |
| // this.log(`Transmuxing ${frag.sn} of [${details.startSN} ,${details.endSN}],level ${frag.level}, cc ${frag.cc}`); | |
| const transmuxer = (this.transmuxer = | |
| this.transmuxer || | |
| new TransmuxerInterface( | |
| this.hls, | |
| PlaylistLevelType.MAIN, | |
| this._handleTransmuxComplete.bind(this), | |
| this._handleTransmuxerFlush.bind(this), | |
| )); | |
| const partIndex = part ? part.index : -1; | |
| const partial = partIndex !== -1; | |
| const chunkMeta = new ChunkMetadata( | |
| frag.level, | |
| frag.sn, | |
| frag.stats.chunkCount, | |
| payload.byteLength, | |
| partIndex, | |
| partial, | |
| ); | |
| const initPTS = this.initPTS[frag.cc]; | |
| transmuxer.push( | |
| payload, | |
| initSegmentData, | |
| audioCodec, | |
| videoCodec, | |
| frag, | |
| part, | |
| details.totalduration, | |
| accurateTimeOffset, | |
| chunkMeta, | |
| initPTS, | |
| ); | |
| } | |
| private onAudioTrackSwitching( | |
| event: Events.AUDIO_TRACK_SWITCHING, | |
| data: AudioTrackSwitchingData, | |
| ) { | |
| const hls = this.hls; | |
| // if any URL found on new audio track, it is an alternate audio track | |
| const fromAltAudio = this.altAudio !== AlternateAudio.DISABLED; | |
| const altAudio = useAlternateAudio(data.url, hls); | |
| // 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) { | |
| this.log( | |
| 'Switching on main audio, use media.buffered to schedule main fragment loading', | |
| ); | |
| this.mediaBuffer = this.media; | |
| const fragCurrent = this.fragCurrent; | |
| // we need to refill audio buffer from main: cancel any frag loading to speed up audio switch | |
| if (fragCurrent) { | |
| this.log('Switching to main audio track, cancel main fragment load'); | |
| fragCurrent.abortRequests(); | |
| this.fragmentTracker.removeFragment(fragCurrent); | |
| } | |
| // destroy transmuxer to force init segment generation (following audio switch) | |
| this.resetTransmuxer(); | |
| // switch to IDLE state to load new fragment | |
| this.resetLoadingState(); | |
| } else if (this.audioOnly) { | |
| // Reset audio transmuxer so when switching back to main audio we're not still appending where we left off | |
| this.resetTransmuxer(); | |
| } | |
| // If switching from alt to main audio, flush all audio and trigger track switched | |
| if (fromAltAudio) { | |
| this.altAudio = AlternateAudio.DISABLED; | |
| this.fragmentTracker.removeAllFragments(); | |
| hls.once(Events.BUFFER_FLUSHED, () => { | |
| if (!this.hls as any) { | |
| return; | |
| } | |
| this.hls.trigger(Events.AUDIO_TRACK_SWITCHED, data); | |
| }); | |
| hls.trigger(Events.BUFFER_FLUSHING, { | |
| startOffset: 0, | |
| endOffset: Number.POSITIVE_INFINITY, | |
| type: null, | |
| }); | |
| return; | |
| } | |
| hls.trigger(Events.AUDIO_TRACK_SWITCHED, data); | |
| } else { | |
| this.altAudio = AlternateAudio.SWITCHING; | |
| } | |
| } | |
| private onAudioTrackSwitched( | |
| event: Events.AUDIO_TRACK_SWITCHED, | |
| data: AudioTrackSwitchedData, | |
| ) { | |
| const altAudio = useAlternateAudio(data.url, this.hls); | |
| if (altAudio) { | |
| const 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) { | |
| this.log( | |
| 'Switching on alternate audio, use video.buffered to schedule main fragment loading', | |
| ); | |
| this.mediaBuffer = videoBuffer; | |
| } | |
| } | |
| this.altAudio = altAudio | |
| ? AlternateAudio.SWITCHED | |
| : AlternateAudio.DISABLED; | |
| this.tick(); | |
| } | |
| private onBufferCreated( | |
| event: Events.BUFFER_CREATED, | |
| data: BufferCreatedData, | |
| ) { | |
| const tracks = data.tracks; | |
| let mediaTrack: BufferCreatedTrack | undefined; | |
| let name: string | undefined; | |
| let alternate = false; | |
| for (const type in tracks) { | |
| const track: BufferCreatedTrack = tracks[type]; | |
| if (track.id === 'main') { | |
| name = type; | |
| mediaTrack = track; | |
| // keep video source buffer reference | |
| if (type === 'video') { | |
| const videoTrack = tracks[type]; | |
| if (videoTrack) { | |
| this.videoBuffer = videoTrack.buffer; | |
| } | |
| } | |
| } else { | |
| alternate = true; | |
| } | |
| } | |
| if (alternate && mediaTrack) { | |
| this.log( | |
| `Alternate track found, use ${name}.buffered to schedule main fragment loading`, | |
| ); | |
| this.mediaBuffer = mediaTrack.buffer; | |
| } else { | |
| this.mediaBuffer = this.media; | |
| } | |
| } | |
| private onFragBuffered(event: Events.FRAG_BUFFERED, data: FragBufferedData) { | |
| const { frag, part } = data; | |
| const bufferedMainFragment = frag.type === PlaylistLevelType.MAIN; | |
| if (bufferedMainFragment) { | |
| if (this.fragContextChanged(frag)) { | |
| // If a level switch was requested while a fragment was buffering, it will emit the FRAG_BUFFERED event upon completion | |
| // Avoid setting state back to IDLE, since that will interfere with a level switch | |
| this.warn( | |
| `Fragment ${frag.sn}${part ? ' p: ' + part.index : ''} of level ${ | |
| frag.level | |
| } finished buffering, but was aborted. state: ${this.state}`, | |
| ); | |
| if (this.state === State.PARSED) { | |
| this.state = State.IDLE; | |
| } | |
| return; | |
| } | |
| const stats = part ? part.stats : frag.stats; | |
| this.fragLastKbps = Math.round( | |
| (8 * stats.total) / (stats.buffering.end - stats.loading.first), | |
| ); | |
| if (isMediaFragment(frag)) { | |
| this.fragPrevious = frag; | |
| } | |
| this.fragBufferedComplete(frag, part); | |
| } | |
| const media = this.media; | |
| if (!media) { | |
| return; | |
| } | |
| if (!this._hasEnoughToStart && BufferHelper.getBuffered(media).length) { | |
| this._hasEnoughToStart = true; | |
| this.seekToStartPos(); | |
| } | |
| if (bufferedMainFragment) { | |
| this.tick(); | |
| } | |
| } | |
| public get hasEnoughToStart(): boolean { | |
| return this._hasEnoughToStart; | |
| } | |
| protected onError(event: Events.ERROR, data: ErrorData) { | |
| if (data.fatal) { | |
| this.state = State.ERROR; | |
| return; | |
| } | |
| switch (data.details) { | |
| case ErrorDetails.FRAG_GAP: | |
| case ErrorDetails.FRAG_PARSING_ERROR: | |
| case ErrorDetails.FRAG_DECRYPT_ERROR: | |
| case ErrorDetails.FRAG_LOAD_ERROR: | |
| case ErrorDetails.FRAG_LOAD_TIMEOUT: | |
| case ErrorDetails.KEY_LOAD_ERROR: | |
| case ErrorDetails.KEY_LOAD_TIMEOUT: | |
| this.onFragmentOrKeyLoadError(PlaylistLevelType.MAIN, data); | |
| break; | |
| case ErrorDetails.LEVEL_LOAD_ERROR: | |
| case ErrorDetails.LEVEL_LOAD_TIMEOUT: | |
| case ErrorDetails.LEVEL_PARSING_ERROR: | |
| // 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 && | |
| data.context?.type === PlaylistContextType.LEVEL | |
| ) { | |
| this.state = State.IDLE; | |
| } | |
| break; | |
| case ErrorDetails.BUFFER_ADD_CODEC_ERROR: | |
| case ErrorDetails.BUFFER_APPEND_ERROR: | |
| if (data.parent !== 'main') { | |
| return; | |
| } | |
| if (this.reduceLengthAndFlushBuffer(data)) { | |
| this.resetLoadingState(); | |
| } | |
| break; | |
| case ErrorDetails.BUFFER_FULL_ERROR: | |
| if (data.parent !== 'main') { | |
| return; | |
| } | |
| if (this.reduceLengthAndFlushBuffer(data)) { | |
| const isAssetPlayer = | |
| !this.config.interstitialsController && this.config.assetPlayerId; | |
| if (isAssetPlayer) { | |
| // Use currentTime in buffer estimate to prevent loading more until playback advances | |
| this._hasEnoughToStart = true; | |
| } else { | |
| this.flushMainBuffer(0, Number.POSITIVE_INFINITY); | |
| } | |
| } | |
| break; | |
| case ErrorDetails.INTERNAL_EXCEPTION: | |
| this.recoverWorkerError(data); | |
| break; | |
| default: | |
| break; | |
| } | |
| } | |
| private 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._hasEnoughToStart) { | |
| this.startFragRequested = false; | |
| this.nextLoadPosition = this.lastCurrentTime; | |
| } | |
| this.tickImmediate(); | |
| } | |
| private onBufferFlushed( | |
| event: Events.BUFFER_FLUSHED, | |
| { type }: BufferFlushedData, | |
| ) { | |
| if (type !== ElementaryStreamTypes.AUDIO || !this.altAudio) { | |
| const mediaBuffer = | |
| (type === ElementaryStreamTypes.VIDEO | |
| ? this.videoBuffer | |
| : this.mediaBuffer) || this.media; | |
| if (mediaBuffer) { | |
| this.afterBufferFlushed(mediaBuffer, type, PlaylistLevelType.MAIN); | |
| this.tick(); | |
| } | |
| } | |
| } | |
| private onLevelsUpdated( | |
| event: Events.LEVELS_UPDATED, | |
| data: LevelsUpdatedData, | |
| ) { | |
| if (this.level > -1 && this.fragCurrent) { | |
| this.level = this.fragCurrent.level; | |
| if (this.level === -1) { | |
| this.resetWhenMissingContext(this.fragCurrent); | |
| } | |
| } | |
| this.levels = data.levels; | |
| } | |
| public swapAudioCodec() { | |
| this.audioCodecSwap = !this.audioCodecSwap; | |
| } | |
| /** | |
| * Seeks to the set startPosition if not equal to the mediaElement's current time. | |
| */ | |
| protected seekToStartPos() { | |
| const { media } = this; | |
| if (!media) { | |
| return; | |
| } | |
| const currentTime = media.currentTime; | |
| let startPosition = this.startPosition; | |
| // 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 | |
| if (startPosition >= 0 && currentTime < startPosition) { | |
| if (media.seeking) { | |
| this.log( | |
| `could not seek to ${startPosition}, already seeking at ${currentTime}`, | |
| ); | |
| return; | |
| } | |
| // Offset start position by timeline offset | |
| const timelineOffset = this.timelineOffset; | |
| if (timelineOffset && startPosition) { | |
| startPosition += timelineOffset; | |
| } | |
| const details = this.getLevelDetails(); | |
| const buffered = BufferHelper.getBuffered(media); | |
| const bufferStart = buffered.length ? buffered.start(0) : 0; | |
| const delta = bufferStart - startPosition; | |
| const skipTolerance = Math.max( | |
| this.config.maxBufferHole, | |
| this.config.maxFragLookUpTolerance, | |
| ); | |
| if ( | |
| this.config.startOnSegmentBoundary || | |
| (delta > 0 && | |
| (delta < skipTolerance || | |
| (this.loadingParts && delta < 2 * (details?.partTarget || 0)))) | |
| ) { | |
| this.log(`adjusting start position by ${delta} to match buffer start`); | |
| startPosition += delta; | |
| this.startPosition = startPosition; | |
| } | |
| if (currentTime < startPosition) { | |
| this.log( | |
| `seek to target start position ${startPosition} from current time ${currentTime} buffer start ${bufferStart}`, | |
| ); | |
| media.currentTime = startPosition; | |
| } | |
| } | |
| } | |
| private _getAudioCodec(currentLevel) { | |
| let audioCodec = this.config.defaultAudioCodec || currentLevel.audioCodec; | |
| if (this.audioCodecSwap && audioCodec) { | |
| this.log('Swapping audio codec'); | |
| if (audioCodec.indexOf('mp4a.40.5') !== -1) { | |
| audioCodec = 'mp4a.40.2'; | |
| } else { | |
| audioCodec = 'mp4a.40.5'; | |
| } | |
| } | |
| return audioCodec; | |
| } | |
| private _loadBitrateTestFrag(fragment: Fragment, level: Level) { | |
| fragment.bitrateTest = true; | |
| this._doFragLoad(fragment, level) | |
| .then((data) => { | |
| const { hls } = this; | |
| const frag = data?.frag; | |
| if (!frag || this.fragContextChanged(frag)) { | |
| return; | |
| } | |
| level.fragmentError = 0; | |
| this.state = State.IDLE; | |
| this.startFragRequested = false; | |
| this.bitrateTest = false; | |
| const stats = frag.stats; | |
| // Bitrate tests fragments are neither parsed nor buffered | |
| stats.parsing.start = | |
| stats.parsing.end = | |
| stats.buffering.start = | |
| stats.buffering.end = | |
| self.performance.now(); | |
| hls.trigger(Events.FRAG_LOADED, data as FragLoadedData); | |
| frag.bitrateTest = false; | |
| }) | |
| .catch((reason) => { | |
| if (this.state === State.STOPPED || this.state === State.ERROR) { | |
| return; | |
| } | |
| this.warn(reason); | |
| this.resetFragmentLoading(fragment); | |
| }); | |
| } | |
| private _handleTransmuxComplete(transmuxResult: TransmuxerResult) { | |
| const id = this.playlistType; | |
| const { hls } = this; | |
| const { remuxResult, chunkMeta } = transmuxResult; | |
| const context = this.getCurrentContext(chunkMeta); | |
| if (!context) { | |
| this.resetWhenMissingContext(chunkMeta); | |
| return; | |
| } | |
| const { frag, part, level } = context; | |
| const { video, text, id3, initSegment } = remuxResult; | |
| const { details } = level; | |
| // The audio-stream-controller handles audio buffering if Hls.js is playing an alternate audio track | |
| const audio = this.altAudio ? undefined : remuxResult.audio; | |
| // Check if the current fragment has been aborted. We check this by first seeing if we're still playing the current level. | |
| // If we are, subsequently check if the currently loading fragment (fragCurrent) has changed. | |
| if (this.fragContextChanged(frag)) { | |
| this.fragmentTracker.removeFragment(frag); | |
| return; | |
| } | |
| this.state = State.PARSING; | |
| if (initSegment) { | |
| const tracks = initSegment.tracks; | |
| if (tracks) { | |
| const mapFragment = frag.initSegment || frag; | |
| if (this.unhandledEncryptionError(initSegment, frag)) { | |
| return; | |
| } | |
| this._bufferInitSegment(level, tracks, mapFragment, chunkMeta); | |
| hls.trigger(Events.FRAG_PARSING_INIT_SEGMENT, { | |
| frag: mapFragment, | |
| id, | |
| tracks, | |
| }); | |
| } | |
| const baseTime = initSegment.initPTS as number; | |
| const timescale = initSegment.timescale as number; | |
| const initPTS = this.initPTS[frag.cc]; | |
| if ( | |
| Number.isFinite(baseTime) && | |
| ((!initPTS as any) || | |
| initPTS.baseTime !== baseTime || | |
| initPTS.timescale !== timescale) | |
| ) { | |
| const trackId = initSegment.trackId as number; | |
| this.initPTS[frag.cc] = { | |
| baseTime, | |
| timescale, | |
| trackId, | |
| }; | |
| hls.trigger(Events.INIT_PTS_FOUND, { | |
| frag, | |
| id, | |
| initPTS: baseTime, | |
| timescale, | |
| trackId, | |
| }); | |
| } | |
| } | |
| // Avoid buffering if backtracking this fragment | |
| if (video && details) { | |
| if (audio && video.type === 'audiovideo') { | |
| this.logMuxedErr(frag); | |
| } | |
| const prevFrag = details.fragments[frag.sn - 1 - details.startSN]; | |
| const isFirstFragment = frag.sn === details.startSN; | |
| const isFirstInDiscontinuity = | |
| (!prevFrag as any) || frag.cc > prevFrag.cc; | |
| if (remuxResult.independent !== false) { | |
| const { startPTS, endPTS, startDTS, endDTS } = video; | |
| if (part) { | |
| part.elementaryStreams[video.type] = { | |
| startPTS, | |
| endPTS, | |
| startDTS, | |
| endDTS, | |
| }; | |
| } else { | |
| if ( | |
| video.firstKeyFrame && | |
| video.independent && | |
| chunkMeta.id === 1 && | |
| !isFirstInDiscontinuity | |
| ) { | |
| this.couldBacktrack = true; | |
| } | |
| if (video.dropped && video.independent) { | |
| // Backtrack if dropped frames create a gap after currentTime | |
| const bufferInfo = this.getMainFwdBufferInfo(); | |
| const targetBufferTime = | |
| (bufferInfo ? bufferInfo.end : this.getLoadPosition()) + | |
| this.config.maxBufferHole; | |
| const startTime = video.firstKeyFramePTS | |
| ? video.firstKeyFramePTS | |
| : startPTS; | |
| if ( | |
| !isFirstFragment && | |
| targetBufferTime < startTime - this.config.maxBufferHole && | |
| !isFirstInDiscontinuity | |
| ) { | |
| this.backtrack(frag); | |
| return; | |
| } else if (isFirstInDiscontinuity) { | |
| // Mark segment with a gap to avoid loop loading | |
| frag.gap = true; | |
| } | |
| // Set video stream start to fragment start so that truncated samples do not distort the timeline, and mark it partial | |
| frag.setElementaryStreamInfo( | |
| video.type as ElementaryStreamTypes, | |
| frag.start, | |
| endPTS, | |
| frag.start, | |
| endDTS, | |
| true, | |
| ); | |
| } else if ( | |
| isFirstFragment && | |
| startPTS - (details.appliedTimelineOffset || 0) > MAX_START_GAP_JUMP | |
| ) { | |
| // Mark segment with a gap to skip large start gap | |
| frag.gap = true; | |
| } | |
| } | |
| frag.setElementaryStreamInfo( | |
| video.type as ElementaryStreamTypes, | |
| startPTS, | |
| endPTS, | |
| startDTS, | |
| endDTS, | |
| ); | |
| if (this.backtrackFragment) { | |
| this.backtrackFragment = frag; | |
| } | |
| this.bufferFragmentData( | |
| video, | |
| frag, | |
| part, | |
| chunkMeta, | |
| isFirstFragment || isFirstInDiscontinuity, | |
| ); | |
| } else if (isFirstFragment || isFirstInDiscontinuity) { | |
| // Mark segment with a gap to avoid loop loading | |
| frag.gap = true; | |
| } else { | |
| this.backtrack(frag); | |
| return; | |
| } | |
| } | |
| if (audio) { | |
| const { startPTS, endPTS, startDTS, endDTS } = audio; | |
| if (part) { | |
| part.elementaryStreams[ElementaryStreamTypes.AUDIO] = { | |
| startPTS, | |
| endPTS, | |
| startDTS, | |
| endDTS, | |
| }; | |
| } | |
| frag.setElementaryStreamInfo( | |
| ElementaryStreamTypes.AUDIO, | |
| startPTS, | |
| endPTS, | |
| startDTS, | |
| endDTS, | |
| ); | |
| this.bufferFragmentData(audio, frag, part, chunkMeta); | |
| } | |
| if (details && id3?.samples.length) { | |
| const emittedID3: FragParsingMetadataData = { | |
| id, | |
| frag, | |
| details, | |
| samples: id3.samples, | |
| }; | |
| hls.trigger(Events.FRAG_PARSING_METADATA, emittedID3); | |
| } | |
| if (details && text) { | |
| const emittedText: FragParsingUserdataData = { | |
| id, | |
| frag, | |
| details, | |
| samples: text.samples, | |
| }; | |
| hls.trigger(Events.FRAG_PARSING_USERDATA, emittedText); | |
| } | |
| } | |
| private logMuxedErr(frag: Fragment) { | |
| this.warn( | |
| `${isMediaFragment(frag) ? 'Media' : 'Init'} segment with muxed audiovideo where only video expected: ${frag.url}`, | |
| ); | |
| } | |
| private _bufferInitSegment( | |
| currentLevel: Level, | |
| tracks: TrackSet, | |
| frag: Fragment, | |
| chunkMeta: ChunkMetadata, | |
| ) { | |
| if (this.state !== State.PARSING) { | |
| return; | |
| } | |
| this.audioOnly = !!tracks.audio && !tracks.video; | |
| // if audio track is expected to come from audio stream controller, discard any coming from main | |
| if (this.altAudio && !this.audioOnly) { | |
| delete tracks.audio; | |
| if (tracks.audiovideo) { | |
| this.logMuxedErr(frag); | |
| } | |
| } | |
| // include levelCodec in audio and video tracks | |
| const { audio, video, audiovideo } = tracks; | |
| if (audio) { | |
| const levelCodec = currentLevel.audioCodec; | |
| let audioCodec = pickMostCompleteCodecName(audio.codec, levelCodec); | |
| // Add level and profile to make up for remuxer not being able to parse full codec | |
| // (logger warning "Unhandled audio codec...") | |
| if (audioCodec === 'mp4a') { | |
| audioCodec = 'mp4a.40.5'; | |
| } | |
| // Handle `audioCodecSwitch` | |
| const ua = navigator.userAgent.toLowerCase(); | |
| if (this.audioCodecSwitch) { | |
| if (audioCodec) { | |
| if (audioCodec.indexOf('mp4a.40.5') !== -1) { | |
| audioCodec = 'mp4a.40.2'; | |
| } else { | |
| audioCodec = 'mp4a.40.5'; | |
| } | |
| } | |
| // In the case that AAC and HE-AAC audio codecs are signalled in manifest, | |
| // force HE-AAC, as it seems that most browsers prefers it. | |
| // don't force HE-AAC if mono stream, or in Firefox | |
| const audioMetadata = audio.metadata; | |
| if ( | |
| audioMetadata && | |
| 'channelCount' in audioMetadata && | |
| (audioMetadata.channelCount || 1) !== 1 && | |
| 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 ( | |
| audioCodec && | |
| audioCodec.indexOf('mp4a.40.5') !== -1 && | |
| ua.indexOf('android') !== -1 && | |
| audio.container !== 'audio/mpeg' | |
| ) { | |
| // Exclude mpeg audio | |
| audioCodec = 'mp4a.40.2'; | |
| this.log(`Android: force audio codec to ${audioCodec}`); | |
| } | |
| if (levelCodec && levelCodec !== audioCodec) { | |
| this.log( | |
| `Swapping manifest audio codec "${levelCodec}" for "${audioCodec}"`, | |
| ); | |
| } | |
| audio.levelCodec = audioCodec; | |
| audio.id = PlaylistLevelType.MAIN; | |
| this.log( | |
| `Init audio buffer, container:${ | |
| audio.container | |
| }, codecs[selected/level/parsed]=[${audioCodec || ''}/${ | |
| levelCodec || '' | |
| }/${audio.codec}]`, | |
| ); | |
| delete tracks.audiovideo; | |
| } | |
| if (video) { | |
| video.levelCodec = currentLevel.videoCodec; | |
| video.id = PlaylistLevelType.MAIN; | |
| const parsedVideoCodec = video.codec; | |
| if (parsedVideoCodec?.length === 4) { | |
| // Make up for passthrough-remuxer not being able to parse full codec | |
| // (logger warning "Unhandled video codec...") | |
| switch (parsedVideoCodec) { | |
| case 'hvc1': | |
| case 'hev1': | |
| video.codec = 'hvc1.1.6.L120.90'; | |
| break; | |
| case 'av01': | |
| video.codec = 'av01.0.04M.08'; | |
| break; | |
| case 'avc1': | |
| video.codec = 'avc1.42e01e'; | |
| break; | |
| } | |
| } | |
| this.log( | |
| `Init video buffer, container:${ | |
| video.container | |
| }, codecs[level/parsed]=[${currentLevel.videoCodec || ''}/${ | |
| parsedVideoCodec | |
| }]${video.codec !== parsedVideoCodec ? ' parsed-corrected=' + video.codec : ''}${video.supplemental ? ' supplemental=' + video.supplemental : ''}`, | |
| ); | |
| delete tracks.audiovideo; | |
| } | |
| if (audiovideo) { | |
| this.log( | |
| `Init audiovideo buffer, container:${audiovideo.container}, codecs[level/parsed]=[${currentLevel.codecs}/${audiovideo.codec}]`, | |
| ); | |
| delete tracks.video; | |
| delete tracks.audio; | |
| } | |
| const trackTypes = Object.keys(tracks); | |
| if (trackTypes.length) { | |
| this.hls.trigger(Events.BUFFER_CODECS, tracks as BufferCodecsData); | |
| if (!this.hls as any) { | |
| // Exit after fatal tracks error | |
| return; | |
| } | |
| // loop through tracks that are going to be provided to bufferController | |
| trackTypes.forEach((trackName) => { | |
| const track = tracks[trackName] as Track; | |
| const initSegment = track.initSegment; | |
| if (initSegment?.byteLength) { | |
| this.hls.trigger(Events.BUFFER_APPENDING, { | |
| type: trackName as SourceBufferName, | |
| data: initSegment, | |
| frag, | |
| part: null, | |
| chunkMeta, | |
| parent: frag.type, | |
| }); | |
| } | |
| }); | |
| } | |
| // trigger handler right now | |
| this.tickImmediate(); | |
| } | |
| public getMainFwdBufferInfo(): BufferInfo | null { | |
| // Observe video SourceBuffer (this.mediaBuffer) only when alt-audio is used, otherwise observe combined media buffer | |
| const bufferOutput = | |
| this.mediaBuffer && this.altAudio === AlternateAudio.SWITCHED | |
| ? this.mediaBuffer | |
| : this.media; | |
| return this.getFwdBufferInfo(bufferOutput, PlaylistLevelType.MAIN); | |
| } | |
| public get maxBufferLength(): number { | |
| const { levels, level } = this; | |
| const levelInfo = levels?.[level]; | |
| if (!levelInfo) { | |
| return this.config.maxBufferLength; | |
| } | |
| return this.getMaxBufferLength(levelInfo.maxBitrate); | |
| } | |
| private backtrack(frag: Fragment) { | |
| this.couldBacktrack = true; | |
| // Causes findFragments to backtrack through fragments to find the keyframe | |
| this.backtrackFragment = frag; | |
| this.resetTransmuxer(); | |
| this.flushBufferGap(frag); | |
| this.fragmentTracker.removeFragment(frag); | |
| this.fragPrevious = null; | |
| this.nextLoadPosition = frag.start; | |
| this.state = State.IDLE; | |
| } | |
| private checkFragmentChanged() { | |
| const video = this.media; | |
| let fragPlayingCurrent: Fragment | null = null; | |
| if (video && video.readyState > 1 && video.seeking === false) { | |
| const 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 (BufferHelper.isBuffered(video, currentTime)) { | |
| fragPlayingCurrent = this.getAppendedFrag(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.getAppendedFrag(currentTime + 0.1); | |
| } | |
| if (fragPlayingCurrent) { | |
| this.backtrackFragment = null; | |
| const fragPlaying = this.fragPlaying; | |
| const fragCurrentLevel = fragPlayingCurrent.level; | |
| if ( | |
| !fragPlaying || | |
| fragPlayingCurrent.sn !== fragPlaying.sn || | |
| fragPlaying.level !== fragCurrentLevel | |
| ) { | |
| this.fragPlaying = fragPlayingCurrent; | |
| this.hls.trigger(Events.FRAG_CHANGED, { frag: fragPlayingCurrent }); | |
| if (!fragPlaying || fragPlaying.level !== fragCurrentLevel) { | |
| this.hls.trigger(Events.LEVEL_SWITCHED, { | |
| level: fragCurrentLevel, | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| get nextLevel(): number { | |
| const frag = this.nextBufferedFrag; | |
| if (frag) { | |
| return frag.level; | |
| } | |
| return -1; | |
| } | |
| get currentFrag(): Fragment | null { | |
| if (this.fragPlaying) { | |
| return this.fragPlaying; | |
| } | |
| const currentTime = this.media?.currentTime || this.lastCurrentTime; | |
| if (Number.isFinite(currentTime)) { | |
| return this.getAppendedFrag(currentTime); | |
| } | |
| return null; | |
| } | |
| get currentProgramDateTime(): Date | null { | |
| const currentTime = this.media?.currentTime || this.lastCurrentTime; | |
| if (Number.isFinite(currentTime)) { | |
| const details = this.getLevelDetails(); | |
| const frag = | |
| this.currentFrag || | |
| (details | |
| ? findFragmentByPTS(null, details.fragments, currentTime) | |
| : null); | |
| if (frag) { | |
| const programDateTime = frag.programDateTime; | |
| if (programDateTime !== null) { | |
| const epocMs = programDateTime + (currentTime - frag.start) * 1000; | |
| return new Date(epocMs); | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| get currentLevel(): number { | |
| const frag = this.currentFrag; | |
| if (frag) { | |
| return frag.level; | |
| } | |
| return -1; | |
| } | |
| get nextBufferedFrag() { | |
| const frag = this.currentFrag; | |
| if (frag) { | |
| return this.followingBufferedFrag(frag); | |
| } | |
| return null; | |
| } | |
| get forceStartLoad() { | |
| return this._forceStartLoad; | |
| } | |
| } | |
Xet Storage Details
- Size:
- 55.1 kB
- Xet hash:
- 2c209ef4c4e65587364ea1e3cb857ca09f41fa61cef44e9bb04f4c73c2da24c8
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.