diff --git a/.travis.yml b/.travis.yml index e33ab068e..f02560293 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,7 @@ branches: - /(release).*/ stages: - build + - deploy - name: test # Run test stage only if cron job if: type = cron @@ -25,11 +26,7 @@ env: - BROWSER=edge # - BROWSER=firefox before_install: - - openssl aes-256-cbc -K $encrypted_f186f3e7458a_key -iv $encrypted_f186f3e7458a_iv -in travis_deploy.enc -out travis_deploy -d - npm install -g gulp - - chmod 600 travis_deploy - - eval `ssh-agent -s` - - ssh-add travis_deploy install: - npm install jobs: @@ -40,12 +37,19 @@ jobs: - npm run build # Build/generate the jsdoc - npm run doc - # Package and deploy version on project's pages - - npm run deploy + - | + if [ "$TRAVIS_BRANCH" == "master" ] || [ "$TRAVIS_BRANCH" == "development" ]; then + # Package and deploy version on project's pages + openssl aes-256-cbc -K $encrypted_f186f3e7458a_key -iv $encrypted_f186f3e7458a_iv -in travis_deploy.enc -out travis_deploy -d + chmod 600 travis_deploy + eval `ssh-agent -s` + ssh-add travis_deploy + npm run deploy + fi deploy: api_key: secure: fLr/s6xT5T2mh2UaPUtt3UlkdmvRkqzOlolJss3c6OICeFegFmx/ieuf8iACnB5u3fZ1pgY9/wIXq9f6uKUpTsDQd/B0uALpe/Nzv/OFGkC1S8qk66Z9VC4/LNkDSEOryAKDDPwMgcZgxyPe5u20NrVUKahMB0B4vhYKwGle5LQ= on: branch: master tags: true - skip_cleanup: true \ No newline at end of file + skip_cleanup: true diff --git a/README.md b/README.md index d17334c08..cd0d3c3e4 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,6 @@ In the case of protected content, here is an example illustrating setting of the laURL: "", withCredentials: "", cdmData: "", // Supported by PlayReady key system (using MS-prefixed EME API) only - pssh: "" // Considered for Widevine key system only serverCertificate: "" audioRobustness: "" // Considered for Widevine key system only videoRobustness: "" // Considered for Widevine key system only diff --git a/RELEASES NOTES.txt b/RELEASES NOTES.txt index 266e0a988..f24dba89a 100644 --- a/RELEASES NOTES.txt +++ b/RELEASES NOTES.txt @@ -1,9 +1,20 @@ +### Release Notes v1.14.0 (2018/02/08) +* [MSS] Add automatic Widevine pssh generation [#216] +* [MSS] Detect PlayReady messages encoding [#207] +* Avoid seeking to stream duration [#219] +* Bugs fixing: +* - Do not remove past buffers for generic WebKit [#205] +* - Fix ArrayBuffer.isView method implementation [#206] +* - Fix initData type passing to CDM for ProtectionModel_01b [#208] +* - Perform null check on playBackQuality [#211] +* - [MSS] Fix for live stream with only one entry in tfrf box [#215] + ### Release Notes v1.13.1 (2017/12/01) * Bugs fixing: * - Fix regression on segment download error management ### Release Notes v1.13.0 (2017/11/09) -* [MSS] Enhanced support for live start-over streams, processed as 'static' streams) [#196] +* [MSS] Enhanced support for live start-over streams, processed as 'static' streams [#196] * [MSS] Add support for chunk 'r' (repeat) attribute [#198] * Bugs fixing: * - Bug fix on metrics persistence [#192] diff --git a/app/js/hls/HlsStream.js b/app/js/hls/HlsStream.js index 63fb368fb..dbef3ef36 100644 --- a/app/js/hls/HlsStream.js +++ b/app/js/hls/HlsStream.js @@ -236,7 +236,7 @@ Hls.dependencies.HlsStream = function() { getCertificate = function () { var protData = getKsProtectionData('com.apple.fps.1_0'); if (!protData || !protData.serverCertificate) { - return []; + return new Uint8Array(0); } return BASE64.decodeArray(protData.serverCertificate); }, diff --git a/app/js/mss/MssFragmentController.js b/app/js/mss/MssFragmentController.js index 0c358949e..c927e9c2d 100644 --- a/app/js/mss/MssFragmentController.js +++ b/app/js/mss/MssFragmentController.js @@ -18,72 +18,76 @@ Mss.dependencies.MssFragmentController = function() { var processTfrf = function(request, tfrf, tfdt, adaptation) { var manifest = this.manifestModel.getValue(), - segmentsUpdated = false, segments = adaptation.SegmentTemplate.SegmentTimeline.S_asArray, timescale = adaptation.SegmentTemplate.timescale, entries = tfrf.entry, + entry, segment = null, segmentTime, t = 0, - i = 0, availabilityStartTime = null, type = adaptation.type, range; - // Process tfrf only for live streams + // Process tfrf only for live or start-over streams if (!this.manifestExt.getIsDynamic(manifest) && !this.manifestExt.getIsStartOver(manifest)) { return; } - // Go through tfrf entries + if (entries.length === 0) { + return; + } + + // Consider only first tfrf entry (to avoid pre-condition failure on fragment info requests) + entry = entries[0]; + // !! For tfrf fragment_absolute_time and fragment_duration are returned as goog.math.Long values (see mp4lib) - while (i < entries.length) { - // Check if time is not greater than Number.MAX_SAFE_INTEGER (2^53-1), see MssParser - // => fragment_absolute_timeManifest = original timestamp value as a string (for constructing the fragment request url, see DashHandler) - // => fragment_absolute_time = number value of timestamp (maybe rounded value, but only for 0.1 microsecond) - if (entries[i].fragment_absolute_time.greaterThan(goog.math.Long.fromNumber(Number.MAX_SAFE_INTEGER))) { - entries[i].fragment_absolute_timeManifest = entries[i].fragment_absolute_time.toString(); - } - // Convert goog.math.Long to Number values - entries[i].fragment_absolute_time = entries[i].fragment_absolute_time.toNumber(); - entries[i].fragment_duration = entries[i].fragment_duration.toNumber(); - - // In case of start-over streams, check if we have reached end of original manifest duration (set in timeShiftBufferDepth) - // => then do not update anymore timeline - if (this.manifestExt.getIsStartOver(manifest)) { - // Get first segment time - segmentTime = segments[0].tManifest ? parseFloat(segments[0].tManifest) : segments[0].t; - if (entries[i].fragment_absolute_time > (segmentTime + (manifest.timeShiftBufferDepth * timescale))) { - break; - } - } + // Check if time is not greater than Number.MAX_SAFE_INTEGER (2^53-1), see MssParser + // => fragment_absolute_timeManifest = original timestamp value as a string (for constructing the fragment request url, see DashHandler) + // => fragment_absolute_time = number value of timestamp (maybe rounded value, but only for 0.1 microsecond) + if (entry.fragment_absolute_time.greaterThan(goog.math.Long.fromNumber(Number.MAX_SAFE_INTEGER))) { + entry.fragment_absolute_timeManifest = entry.fragment_absolute_time.toString(); + } - // Get last segment time - segmentTime = segments[segments.length - 1].tManifest ? parseFloat(segments[segments.length - 1].tManifest) : segments[segments.length - 1].t; - // Check if we have to append new segment to timeline - if (entries[i].fragment_absolute_time > segmentTime) { - this.debug.log("[MssFragmentController][" + type + "] Add new segment - t = " + (entries[i].fragment_absolute_time / timescale)); - segment = {}; - segment.t = entries[i].fragment_absolute_time; - segment.d = entries[i].fragment_duration; - // If timestamps starts at 0 relative to 1st segment (dynamic to static) then update segment time - if (segments[0].tManifest) { - segment.t -= parseFloat(segments[0].tManifest) - segments[0].t; - } - // Set tManifest either in case of timestamps greater then 2^53 or in case of dynamic to static streams - if (entries[i].fragment_absolute_timeManifest) { - segment.tManifest = entries[i].fragment_absolute_timeManifest; - } else if (segments[0].tManifest) { - segment.tManifest = entries[i].fragment_absolute_time; - } - segments.push(segment); - segmentsUpdated = true; - } + // Convert goog.math.Long to Number values + entry.fragment_absolute_time = entry.fragment_absolute_time.toNumber(); + entry.fragment_duration = entry.fragment_duration.toNumber(); + + // In case of start-over streams, check if we have reached end of original manifest duration (set in timeShiftBufferDepth) + // => then do not update anymore timeline + if (this.manifestExt.getIsStartOver(manifest)) { + // Get first segment time + segmentTime = segments[0].tManifest ? parseFloat(segments[0].tManifest) : segments[0].t; + if (entry.fragment_absolute_time > (segmentTime + (manifest.timeShiftBufferDepth * timescale))) { + return; + } + } + + // Get last segment time + segmentTime = segments[segments.length - 1].tManifest ? parseFloat(segments[segments.length - 1].tManifest) : segments[segments.length - 1].t; - i += 1; + // Check if we have to append new segment to timeline + if (entry.fragment_absolute_time <= segmentTime) { + return; } + this.debug.log("[MssFragmentController][" + type + "] Add new segment - t = " + (entry.fragment_absolute_time / timescale)); + segment = {}; + segment.t = entry.fragment_absolute_time; + segment.d = entry.fragment_duration; + // If timestamps starts at 0 relative to 1st segment (dynamic to static) then update segment time + if (segments[0].tManifest) { + segment.t -= parseFloat(segments[0].tManifest) - segments[0].t; + } + // Set tManifest either in case of timestamps greater then 2^53 or in case of dynamic to static streams + if (entry.fragment_absolute_timeManifest) { + segment.tManifest = entry.fragment_absolute_timeManifest; + } else if (segments[0].tManifest) { + segment.tManifest = entry.fragment_absolute_time; + } + segments.push(segment); + // In case of static start-over streams, update content duration if (this.manifestExt.getIsStartOver(manifest)) { if (type === 'video') { @@ -95,48 +99,21 @@ Mss.dependencies.MssFragmentController = function() { } return; } - - // Update segment timeline in case the timestamps from tfrf differ from timestamps in Manifest. - // In that case we consider tfrf timing - // var j = 0, - // segmentId = -1, - // for (j = segments.length - 1; j >= 0; j -= 1) { - // if (segments[j].t === tfdt.baseMediaDecodeTime) { - // segmentId = j; - // break; - // } - // } - // if (segmentId >= 0) { - // for (i = 0; i < entries.length; i += 1) { - // if (segmentId + i < segments.length) { - // t = segments[segmentId + i].t; - // if ((t + segments[segmentId + i].d) !== entries[i].fragment_absolute_time) { - // segments[segmentId + i].t = entries[i].fragment_absolute_time; - // segments[segmentId + i].d = entries[i].fragment_duration; - // this.debug.log("[MssFragmentController] Correct tfrf time = " + entries[i].fragment_absolute_time + " and duration = " + entries[i].fragment_duration); - // segmentsUpdated = true; - // } - // } - // } - // } - - // Update segment timeline according to DVR window - if (manifest.timeShiftBufferDepth && manifest.timeShiftBufferDepth > 0) { - if (segmentsUpdated) { - // Get timestamp of the last segment - segment = segments[segments.length - 1]; - t = segment.t; - - // Determine the segments' availability start time - availabilityStartTime = t - (manifest.timeShiftBufferDepth * timescale); - - // Remove segments prior to availability start time + // In case of live streams, update segment timeline according to DVR window + else if (manifest.timeShiftBufferDepth && manifest.timeShiftBufferDepth > 0) { + // Get timestamp of the last segment + segment = segments[segments.length - 1]; + t = segment.t; + + // Determine the segments' availability start time + availabilityStartTime = t - (manifest.timeShiftBufferDepth * timescale); + + // Remove segments prior to availability start time + segment = segments[0]; + while (segment.t < availabilityStartTime) { + this.debug.log("[MssFragmentController][" + type + "] Remove segment - t = " + (segment.t / timescale)); + segments.splice(0, 1); segment = segments[0]; - while (segment.t < availabilityStartTime) { - this.debug.log("[MssFragmentController][" + type + "] Remove segment - t = " + (segment.t / timescale)); - segments.splice(0, 1); - segment = segments[0]; - } } // Update DVR window range => set range's end to end time of current segment @@ -159,8 +136,7 @@ Mss.dependencies.MssFragmentController = function() { traf = null, tfdt = null, tfrf = null, - pos, - i = 0; + pos; // Create new fragment fragment = mp4lib.deserialize(bytes); @@ -188,9 +164,7 @@ Mss.dependencies.MssFragmentController = function() { } }; } else { - for (i = 0; i < tfrf.length; i += 1) { - processTfrf.call(this, request, tfrf[i], tfdt, adaptation); - } + processTfrf.call(this, request, tfrf[0], tfdt, adaptation); } }, @@ -354,8 +328,8 @@ Mss.dependencies.MssFragmentController = function() { traf.boxes.splice(pos + 1, 0, tfdt); } - // Process tfrf box if (manifest.type === 'dynamic') { + // Process tfrf box tfrf = traf.getBoxesByType("tfrf"); if (tfrf === null || tfrf.length === 0) { throw { @@ -366,10 +340,8 @@ Mss.dependencies.MssFragmentController = function() { } }; } else { - for (i = 0; i < tfrf.length; i += 1) { - processTfrf.call(this, request, tfrf[i], tfdt, adaptation); - traf.removeBoxByType("tfrf"); - } + processTfrf.call(this, request, tfrf[0], tfdt, adaptation); + traf.removeBoxByType("tfrf"); } } diff --git a/app/js/mss/MssFragmentInfoController.js b/app/js/mss/MssFragmentInfoController.js index 01b808132..4a0145205 100644 --- a/app/js/mss/MssFragmentInfoController.js +++ b/app/js/mss/MssFragmentInfoController.js @@ -52,16 +52,16 @@ Mss.dependencies.MssFragmentInfoController = function() { return Q.when(null); }, - start = function() { + start = function(delay) { if (!_ready || _started) { return; } this.debug.info("[MssFragmentInfoController][" + _type + "] START"); _started = true; - _startTime = new Date().getTime(); + _startTime = new Date().getTime() + delay * 1000; - loadNextFragmentInfo.call(this); + delayLoadNextFragmentInfo.call(this, delay); }, stop = function() { @@ -86,8 +86,7 @@ Mss.dependencies.MssFragmentInfoController = function() { var adaptation = _bufferController.getData(), segments = adaptation.SegmentTemplate.SegmentTimeline.S_asArray, - // tak before last segment to avoid precondition failed (412) errors - segment = segments[segments.length - 2], + segment = segments[segments.length - 1], representation = adaptation.Representation_asArray[0], request; diff --git a/app/js/mss/MssParser.js b/app/js/mss/MssParser.js index b6ba20077..a399bdc58 100644 --- a/app/js/mss/MssParser.js +++ b/app/js/mss/MssParser.js @@ -471,7 +471,7 @@ Mss.dependencies.MssParser = function() { return contentProtection; }, - createWidevineContentProtection = function(/*protectionHeader*/) { + createWidevineContentProtection = function(KID) { var contentProtection = {}, keySystem = this.system.getObject("ksWidevine"); @@ -479,9 +479,51 @@ Mss.dependencies.MssParser = function() { contentProtection.schemeIdUri = keySystem.schemeIdURI; contentProtection.value = keySystem.systemString; + // Create Widevine CENC header (Protocol Buffer) with KID value + var wvCencHeader = new Uint8Array(2 + KID.length); + wvCencHeader[0] = 0x12; + wvCencHeader[1] = 0x10; + wvCencHeader.set(KID, 2); + + // Create a pssh box + var length = 12 /* box length, type, version and flags */ + 16 /* SystemID */ + 4 /* data length */ + wvCencHeader.length, + pssh = new Uint8Array(length), + i = 0; + + // Set box length value + pssh[i++] = (length & 0xFF000000) >> 24; + pssh[i++] = (length & 0x00FF0000) >> 16; + pssh[i++] = (length & 0x0000FF00) >> 8; + pssh[i++] = (length & 0x000000FF); + + // Set type ('pssh'), version (0) and flags (0) + pssh.set([0x70, 0x73, 0x73, 0x68, 0x00, 0x00, 0x00, 0x00], i); + i += 8; + + // Set SystemID ('edef8ba9-79d6-4ace-a3c8-27dcd51d21ed') + pssh.set([0xed, 0xef, 0x8b, 0xa9, 0x79, 0xd6, 0x4a, 0xce, 0xa3, 0xc8, 0x27, 0xdc, 0xd5, 0x1d, 0x21, 0xed], i); + i += 16; + + // Set data length value + pssh[i++] = (wvCencHeader.length & 0xFF000000) >> 24; + pssh[i++] = (wvCencHeader.length & 0x00FF0000) >> 16; + pssh[i++] = (wvCencHeader.length & 0x0000FF00) >> 8; + pssh[i++] = (wvCencHeader.length & 0x000000FF); + + // Copy Widevine CENC header + pssh.set(wvCencHeader, i); + + // Convert to BASE64 string + pssh = String.fromCharCode.apply(null, pssh); + pssh = BASE64.encodeASCII(pssh); + + // Add pssh value to ContentProtection + contentProtection.pssh = { + __text: pssh + }; + return contentProtection; }, - /* @endif */ addDVRInfo = function(adaptationSet) { var segmentTemplate = adaptationSet.SegmentTemplate, @@ -574,7 +616,7 @@ Mss.dependencies.MssParser = function() { contentProtections.push(contentProtection); // Create ContentProtection for Widevine (as a CENC protection) - contentProtection = createWidevineContentProtection.call(this, protectionHeader); + contentProtection = createWidevineContentProtection.call(this, KID); contentProtection["cenc:default_KID"] = KID; contentProtections.push(contentProtection); @@ -633,7 +675,7 @@ Mss.dependencies.MssParser = function() { if (adaptations[i].contentType === 'audio' || adaptations[i].contentType === 'video') { segments = adaptations[i].SegmentTemplate.SegmentTimeline.S_asArray; startTime = segments[0].t; - if (!timestampOffset) { + if (timestampOffset === undefined) { timestampOffset = startTime; } timestampOffset = Math.min(timestampOffset, startTime); diff --git a/app/js/streaming/BufferController.js b/app/js/streaming/BufferController.js index 7e883d967..675d6b08b 100644 --- a/app/js/streaming/BufferController.js +++ b/app/js/streaming/BufferController.js @@ -85,6 +85,7 @@ MediaPlayer.dependencies.BufferController = function() { // Patch for Safari: do not remove past buffer in live use case since it generates MEDIA_ERROR_DECODE while appending new segment (see hasEnoughSpaceToAppend()) isSafari = (fingerprint_browser().name === "Safari"), + isWebKit = (fingerprint_browser().name === "WebKit"), // Patch for Firefox: set buffer timestampOffset since on Firefox timestamping is based on CTS (see OnMediaLoaded()) isFirefox = (fingerprint_browser().name === "Firefox"), @@ -454,8 +455,8 @@ MediaPlayer.dependencies.BufferController = function() { isQuotaExceeded = false; - // Patch for Safari: do not remove past buffer since it generates MEDIA_ERROR_DECODE while appending new segment - if (bufferLevel > 1 && !isSafari) { + // Patch for Safari & WebKit: do not remove past buffer since it generates MEDIA_ERROR_DECODE while appending new segment + if (bufferLevel > 1 && !isSafari && !isWebKit) { // Remove outdated buffer parts and requests // (checking bufferLevel ensure buffer is not empty or back to current time) removeBuffer.call(self, -1, getWorkingTime.call(self) - bufferToKeep).then( diff --git a/app/js/streaming/Config.js b/app/js/streaming/Config.js index e82618cf7..64a6b9366 100644 --- a/app/js/streaming/Config.js +++ b/app/js/streaming/Config.js @@ -50,6 +50,8 @@ MediaPlayer.utils.Config = function () { "FragmentLoader.RetryInterval": -1, // Protection parameters "Protection.licensePersistence": -1, + // Other parameters + "backoffSeekToEnd" : 2, // Video parameters "video": { }, diff --git a/app/js/streaming/MediaPlayer.js b/app/js/streaming/MediaPlayer.js index 046ea5645..009f733fb 100644 --- a/app/js/streaming/MediaPlayer.js +++ b/app/js/streaming/MediaPlayer.js @@ -851,7 +851,6 @@ MediaPlayer = function () { "[key_system_name]": { laURL: "[licenser url (optional)]", withCredentials: "[license_request_withCredentials_value (true or false, optional)]", - pssh: "[base64 pssh box (as Base64 string, optional)]", // Considered for Widevine key system only cdmData: "[CDM data (optional)]", // Supported by PlayReady key system (using MS-prefixed EME API) only serverCertificate: "[license_server_certificate (as Base64 string, optional)]", audioRobustness: "[audio_robustness_level (optional)]", // Considered for Widevine key system only @@ -1736,6 +1735,7 @@ MediaPlayer.TRACKS_TYPE = { * @property {number} FragmentLoader.RetryAttempts - Number of retry attempts for downloading segment files when it fails (default value = 2) * @property {number} FragmentLoader.RetryInterval - Interval (in milliseconds) between each retry attempts for downloading segment files (default value = 500) * @property {boolean} Protection.licensePersistence - Provides or not license persistence at application level, in case no persistence is provided by the CDM (default value = false) + * @property {number} backoffSeekToEnd - Backoff value (in seconds) when seeking at end/duration (default value = 2) * @property {Object} video - Video parameters (parameters for video track) * @property {Object} audio - audio parameters (parameters for audio track) */ diff --git a/app/js/streaming/Stream.js b/app/js/streaming/Stream.js index 150dc31c4..479d97174 100644 --- a/app/js/streaming/Stream.js +++ b/app/js/streaming/Stream.js @@ -433,15 +433,15 @@ MediaPlayer.dependencies.Stream = function() { if (fragmentInfoVideoController && dvrStarted === false) { dvrStarted = true; - fragmentInfoVideoController.start(); + fragmentInfoVideoController.start(videoController.getSegmentDuration()); } if (fragmentInfoAudioController) { - fragmentInfoAudioController.start(); + fragmentInfoAudioController.start(audioController.getSegmentDuration()); } if (fragmentInfoTextController && subtitlesEnabled) { - fragmentInfoTextController.start(); + fragmentInfoTextController.start(textController.getSegmentDuration()); } }, @@ -568,7 +568,8 @@ MediaPlayer.dependencies.Stream = function() { }, onSeeking = function() { - var time = this.videoModel.getCurrentTime(); + var time = this.videoModel.getCurrentTime(), + duration = this.videoModel.getDuration(); this.debug.info("[Stream]