UNPKG

shaka-player

Version:
452 lines (422 loc) • 16.5 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.provide('shaka.media.SkipRangeController'); goog.require('shaka.log'); goog.require('shaka.util.NumberUtils'); goog.requireType('shaka.media.SegmentReference'); /** * @summary Owns the set of skip ranges -- regions [start, end) whose segments * are treated as if they do not exist (never fetched, gap-jumped by the * playhead). Player owns the instance so ranges added before load survive * until streaming starts; the streaming side decides when each range actually * becomes a gap. */ shaka.media.SkipRangeController = class { /** * @param {shaka.media.SkipRangeController.PlayerInterface} playerInterface */ constructor(playerInterface) { /** * Disjoint ranges to skip. * @private {!Array<shaka.media.SkipRangeController.SkipRange>} */ this.skipRanges_ = []; /** @private {!shaka.media.SkipRangeController.PlayerInterface} */ this.playerInterface_ = playerInterface; // Alignment verdicts keyed by range identity (a merge/split makes a new // object, so it starts unresolved). A range is "resolved" once it is in // one of these; only alignable ranges may be carved. /** @private {!WeakSet<shaka.media.SkipRangeController.SkipRange>} */ this.alignable_ = new WeakSet(); /** @private {!WeakSet<shaka.media.SkipRangeController.SkipRange>} */ this.unalignable_ = new WeakSet(); } /** * Declares a range [start, end) to be skipped. An empty/reversed interval is * rejected; otherwise the range is always accepted. A new range that * overlaps or touches (within a small tolerance) any existing ranges is * merged with all of them into a single coalesced range -- transitively, so * one add can bridge several existing ranges. The merged set stays disjoint * and sorted by start, so a segment falls in at most one range. The range is * recorded even if it (or the playhead) is currently buffered: the skip is * postponed, acting only once the region can be turned into a real gap (its * span is fully unbuffered) -- the streaming side gates that. Callers apply * any manifest stream-type guard first. * * @param {number} start * @param {number} end * @return {boolean} True if the range was accepted; false only if it was an * empty/reversed interval. */ add(start, end) { if (end <= start) { return false; } const tolerance = shaka.media.SkipRangeController.TOLERANCE_SECONDS; let merged = {start, end}; const untouched = []; // Fold every overlapping/touching range into |merged|. Ranges that don't // touch it are kept as-is. Because the stored set was already disjoint, // one pass coalesces all of them transitively. for (const other of this.skipRanges_) { const overlaps = merged.start <= other.end + tolerance && other.start <= merged.end + tolerance; if (overlaps) { merged = { start: Math.min(merged.start, other.start), end: Math.max(merged.end, other.end), }; } else { untouched.push(other); } } untouched.push(merged); untouched.sort((a, b) => a.start - b.start); this.skipRanges_ = untouched; this.playerInterface_.requestUpdate(); return true; } /** * Subtracts the interval [start, end) from the stored ranges, so its content * is fetched normally again the next time the region is reached. This is * interval subtraction, not an exact-match delete: removing a sub-interval of * a range trims it, and removing from its middle splits it in two. Endpoints * within a small tolerance of a stored range's own boundary snap to it, so a * slightly-off remove doesn't leave a sliver behind (and can fully restore a * range an equally-slightly-off remove would otherwise miss). If streaming * already committed a hole, the playhead still crosses it as an ordinary gap; * re-fetching happens when the region is next reached. * * @param {number} start * @param {number} end */ remove(start, end) { if (end <= start) { return; } const NumberUtils = shaka.util.NumberUtils; const tolerance = shaka.media.SkipRangeController.TOLERANCE_SECONDS; const next = []; let changed = false; for (const r of this.skipRanges_) { // Snap the cut endpoints to this range's own boundaries when they're // within tolerance, so float jitter doesn't leave slivers or miss a // full-range removal. const cutStart = NumberUtils.isFloatEqual(start, r.start, tolerance) ? r.start : start; const cutEnd = NumberUtils.isFloatEqual(end, r.end, tolerance) ? r.end : end; // Cut lies entirely outside this range: keep it untouched. if (cutEnd <= r.start || cutStart >= r.end) { next.push(r); continue; } changed = true; // Left fragment survives if the cut starts after the range start. if (cutStart - r.start > tolerance) { next.push({start: r.start, end: cutStart}); } // Right fragment survives if the cut ends before the range end. if (r.end - cutEnd > tolerance) { next.push({start: cutEnd, end: r.end}); } } if (!changed) { return; } next.sort((a, b) => a.start - b.start); this.skipRanges_ = next; this.playerInterface_.requestUpdate(); } /** * Drops all skip ranges. Already-buffered holes are left as-is; only ranges * not yet reached are affected (their content then fetches normally). */ clear() { this.skipRanges_ = []; } /** * @return {!Array<shaka.media.SkipRangeController.SkipRange>} The active skip * ranges. */ getAll() { return this.skipRanges_; } /** * @return {boolean} True if there are no skip ranges. */ isEmpty() { return this.skipRanges_.length == 0; } /** * The skip range containing |time| (start in [start, end)), or null. Ranges * hold raw request times; comparing each stream's own segment times against * them keeps the decision correct per stream, since audio and video segment * boundaries are not aligned. * * Matching is tolerant on both sides of each boundary so a segment time that * misses the raw boundary by float jitter still lines up: a segment starting * within tolerance *below* |region.start| still counts as inside, and one * starting within tolerance of |region.end| is treated as *at* the end (the * first kept segment) rather than inside -- so nothing extra is dropped. * * @param {number} time * @return {?shaka.media.SkipRangeController.SkipRange} */ getRangeAt(time) { const NumberUtils = shaka.util.NumberUtils; const tolerance = shaka.media.SkipRangeController.TOLERANCE_SECONDS; for (const region of this.skipRanges_) { if (time > region.start - tolerance && time < region.end && !NumberUtils.isFloatEqual(time, region.end, tolerance)) { return region; } } return null; } /** * The skip range |time| falls in that is currently *acting* (unbuffered), or * null. Used only by run-ahead math (timeNeededPast): an already-buffered * range must not push the frontier past it, or it stalls at the buffered * edge. The per-segment skip decision instead uses containingActiveRange, * which is buffer-independent -- see there. * * @param {number} time * @return {?shaka.media.SkipRangeController.SkipRange} */ activeRangeAt(time) { const region = this.forwardRangeAt_(time); if (!region || this.playerInterface_.isRegionBuffered(region.start, region.end)) { return null; } return region; } /** * The skip range a concrete segment [startTime, endTime) may be carved out * of, or null. Backs the per-segment skip/discard decisions on every stream * and entry point (fetch frontier, append guard, prefetch). * * Deliberately buffer-independent: gating on isRegionBuffered is * self-defeating, since the first in-range append flips the range "buffered" * and stops every stream skipping, leaving misaligned partial fills that * gap-jump can't clear (the seek-into-range stall). A range is skippable * only once the reference grid confirms it aligns to segment boundaries on * both ends (markAlignable); until resolved, or if unalignable, this returns * null. * * @param {number} startTime * @param {number} endTime * @return {?shaka.media.SkipRangeController.SkipRange} */ containingActiveRange(startTime, endTime) { const tolerance = shaka.media.SkipRangeController.TOLERANCE_SECONDS; const region = this.forwardRangeAt_(startTime); if (!region || endTime > region.end + tolerance || !this.alignable_.has(region)) { return null; } return region; } /** * Whether a segment may still be dropped by a skip range, so prefetch should * defer. True while its containing range is alignable or unresolved (may * yet be carved); false once the range is confirmed unalignable (it will be * kept). * * @param {number} startTime * @param {number} endTime * @return {boolean} */ mayBeSkipped(startTime, endTime) { const tolerance = shaka.media.SkipRangeController.TOLERANCE_SECONDS; const region = this.forwardRangeAt_(startTime); if (!region || endTime > region.end + tolerance) { return false; } return !this.unalignable_.has(region); } /** * The range |time| falls in when the skip may apply at all: non-empty set and * forward playback. Shared by activeRangeAt (which then adds the buffered * gate) and containingActiveRange (which does not). * * @param {number} time * @return {?shaka.media.SkipRangeController.SkipRange} * @private */ forwardRangeAt_(time) { if (this.isEmpty() || this.playerInterface_.getPlaybackRate() < 0) { // TODO: Support reverse trick play, which walks the index backwards. return null; } return this.getRangeAt(time); } /** * Advances |time| past the acting skip range it falls in (else returns it * unchanged), so run-ahead math treats the gap as absent. An inert range is * left in place, or the frontier would stall at the buffered edge. * * @param {number} time * @return {number} */ timeNeededPast(time) { const region = this.activeRangeAt(time); return region ? region.end : time; } /** * @param {shaka.media.SkipRangeController.SkipRange} region * @return {boolean} Whether the reference grid has resolved this range's * boundary alignment yet (in either alignable_ or unalignable_). */ isResolved(region) { return this.alignable_.has(region) || this.unalignable_.has(region); } /** * @param {shaka.media.SkipRangeController.SkipRange} region * @return {boolean} Whether the range was confirmed not to align to video * segment boundaries (never carved on any stream). */ isUnalignable(region) { return this.unalignable_.has(region); } /** * Records whether |region| aligns to video segment boundaries on both ends, * given the video segments covering its start and end (from the streaming * side, which owns the indices). It aligns when a segment starts at * region.start AND a segment boundary lands at region.end (the end segment * starts or ends there). Aligned ranges snap to those boundaries and become * skippable; misaligned ranges are never skipped and warn once. Runs once * per range. * * A boundary matches an endpoint when it lies within tolerance on either * side; the closest wins, since the window can span more than one boundary. * Callers pass the candidate segments (a lone segment is accepted as * shorthand). * * @param {shaka.media.SkipRangeController.SkipRange} region * @param {?shaka.media.SegmentReference| * !Array<!shaka.media.SegmentReference>} startSegs Segments that may * carry a boundary matching region.start. * @param {?shaka.media.SegmentReference| * !Array<!shaka.media.SegmentReference>} endSegs Segments that may * carry a boundary matching region.end. */ resolveAlignment(region, startSegs, endSegs) { // A hole can only begin where a segment does, so the start must meet a // segment start; the end may meet either boundary, as both are points the // first kept segment can start from. const alignedStart = this.closestBoundary_( region.start, startSegs, (ref) => [ref.startTime]); const alignedEnd = this.closestBoundary_( region.end, endSegs, (ref) => [ref.startTime, ref.endTime]); if (alignedStart == null || alignedEnd == null) { this.markUnalignable_(region); return; } this.markAlignable_(region, alignedStart, alignedEnd); } /** * The segment boundary closest to |time| among |segs|, or null if none is * within tolerance. * * @param {number} time * @param {?shaka.media.SegmentReference| * !Array<!shaka.media.SegmentReference>} segs * @param {function(!shaka.media.SegmentReference):!Array<number>} boundsOf * The boundaries of a segment that |time| may snap to. * @return {?number} * @private */ closestBoundary_(time, segs, boundsOf) { const tolerance = shaka.media.SkipRangeController.TOLERANCE_SECONDS; let best = null; let bestError = Infinity; for (const ref of (Array.isArray(segs) ? segs : [segs])) { if (!ref) { continue; } for (const boundary of boundsOf(ref)) { const error = Math.abs(boundary - time); if (error <= tolerance && error < bestError) { best = boundary; bestError = error; } } } return best; } /** * Snaps |region|'s endpoints to the aligned boundaries (so merge/split stay * aligned) and marks it skippable. * * @param {shaka.media.SkipRangeController.SkipRange} region * @param {number} start * @param {number} end * @private */ markAlignable_(region, start, end) { const changed = region.start !== start || region.end !== end; region.start = start; region.end = end; this.alignable_.add(region); if (changed) { this.skipRanges_.sort((a, b) => a.start - b.start); } } /** * Marks |region| unskippable and warns once (visible by default) that the * app's range could not be honored. * * @param {shaka.media.SkipRangeController.SkipRange} region * @private */ markUnalignable_(region) { this.unalignable_.add(region); shaka.log.warning('addSkipRange(): range [' + region.start + ', ' + region.end + ') does not align to video segment boundaries on both ' + 'ends, so it cannot form a clean hole and will not be skipped; ' + 'align the range to segment boundaries.'); } }; /** * The tolerance, in seconds, for matching skip-range endpoints against segment * boundaries and for coalescing adjacent ranges. Application-supplied times * (e.g. ad-server times from MediaTailor) miss segment boundaries by fractions * of a second; this absorbs that jitter so a slightly-off boundary still lines * up with the segment grid instead of fetching the whole segment. Shared with * shaka.media.StreamingEngine so both sides agree. * * @const {number} */ shaka.media.SkipRangeController.TOLERANCE_SECONDS = 1e-3; /** * A skip range, as the raw application-supplied request times. Each stream * compares its own segment times against these, so misaligned audio/video * boundaries are handled correctly per stream. * * @typedef {{ * start: number, * end: number, * }} */ shaka.media.SkipRangeController.SkipRange; /** * @typedef {{ * requestUpdate: function():void, * getPlaybackRate: function():number, * isRegionBuffered: function(number, number):boolean * }} * * @summary Player interface. All members no-op / report a neutral value before * load (no streaming engine yet), so ranges added early are simply queued. * * requestUpdate schedules an immediate streaming update. * getPlaybackRate is the current playback rate (negative in reverse). * isRegionBuffered reports whether any part of [start, end) is buffered for any * content type currently streaming. */ shaka.media.SkipRangeController.PlayerInterface;