diff --git a/README.md b/README.md index 10af263b3e..438b597fd1 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,8 @@ Shaka Player supports: - Supported embedded in MP4 - SubRip (SRT) - UTF-8 encoding only + - LyRiCs (LRC) + - UTF-8 encoding only Subtitles are rendered by the browser by default. Applications can create a [text display plugin][] for customer rendering to go beyond browser-supported diff --git a/build/types/text b/build/types/text index 2eea94e4d6..62f6d94c38 100644 --- a/build/types/text +++ b/build/types/text @@ -1,5 +1,6 @@ # Optional plugins related to text parsing and displaying. ++../../lib/text/lrc_text_parser.js +../../lib/text/mp4_ttml_parser.js +../../lib/text/mp4_vtt_parser.js +../../lib/text/srt_text_parser.js diff --git a/lib/player.js b/lib/player.js index 067b08fc42..62e92b2b59 100644 --- a/lib/player.js +++ b/lib/player.js @@ -3875,6 +3875,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { 'vtt': 'text/vtt', 'webvtt': 'text/vtt', 'ttml': 'application/ttml+xml', + 'lrc': 'application/x-subtitle-lrc', }[extension]; if (!mimeType) { @@ -4008,6 +4009,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { 'vtt': 'text/vtt', 'webvtt': 'text/vtt', 'ttml': 'application/ttml+xml', + 'lrc': 'application/x-subtitle-lrc', }[extension]; if (!mimeType) { @@ -4038,10 +4040,10 @@ shaka.Player = class extends shaka.util.FakeEventTarget { try { goog.asserts.assert( this.networkingEngine_, 'Need networking engine.'); - const stringData = await this.getTextData_(uri, + const data = await this.getTextData_(uri, this.networkingEngine_, this.config_.streaming.retryParameters); - const vvtText = this.convertToWebVTT_(stringData, mimeType); + const vvtText = this.convertToWebVTT_(data, mimeType); const blob = new Blob([vvtText], {type: 'text/vtt'}); uri = URL.createObjectURL(blob); mimeType = 'text/vtt'; @@ -4135,7 +4137,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { * @param {string} uri * @param {!shaka.net.NetworkingEngine} netEngine * @param {shaka.extern.RetryParameters} retryParams - * @return {!Promise.} + * @return {!Promise.} * @private */ async getTextData_(uri, netEngine, retryParams) { @@ -4146,14 +4148,14 @@ shaka.Player = class extends shaka.util.FakeEventTarget { const response = await netEngine.request(type, request).promise; - return shaka.util.StringUtils.fromUTF8(response.data); + return response.data; } /** * Converts an input string to a WebVTT format string. * - * @param {string} data + * @param {BufferSource} data * @param {string} mimeType * @return {string} * @private @@ -4163,13 +4165,27 @@ shaka.Player = class extends shaka.util.FakeEventTarget { if (mimeType === 'text/srt') { const SrtTextParser = shaka.text.SrtTextParser; if (SrtTextParser) { - return SrtTextParser.srt2webvtt(data); + const string = shaka.util.StringUtils.fromUTF8(data); + return SrtTextParser.srt2webvtt(string); + } else { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.TEXT, + shaka.util.Error.Code.MISSING_TEXT_PLUGIN, + mimeType); + } + } + if (mimeType === 'application/x-subtitle-lrc') { + const LrcTextParser = shaka.text.LrcTextParser; + if (LrcTextParser) { + return LrcTextParser.convertToWebVTT(data, this.video_.duration); + } else { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.TEXT, + shaka.util.Error.Code.MISSING_TEXT_PLUGIN, + mimeType); } - throw new shaka.util.Error( - shaka.util.Error.Severity.CRITICAL, - shaka.util.Error.Category.TEXT, - shaka.util.Error.Code.MISSING_TEXT_PLUGIN, - mimeType); } throw new shaka.util.Error( shaka.util.Error.Severity.RECOVERABLE, diff --git a/lib/text/lrc_text_parser.js b/lib/text/lrc_text_parser.js new file mode 100644 index 0000000000..7905902c40 --- /dev/null +++ b/lib/text/lrc_text_parser.js @@ -0,0 +1,156 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.text.LrcTextParser'); + +goog.require('goog.asserts'); +goog.require('shaka.log'); +goog.require('shaka.text.Cue'); +goog.require('shaka.text.TextEngine'); +goog.require('shaka.util.StringUtils'); + + +/** + * LRC file format: https://en.wikipedia.org/wiki/LRC_(file_format) + * + * @implements {shaka.extern.TextParser} + * @export + */ +shaka.text.LrcTextParser = class { + /** + * @override + * @export + */ + parseInit(data) { + goog.asserts.assert(false, 'LRC does not have init segments'); + } + + /** + * @override + * @export + */ + parseMedia(data, time) { + return shaka.text.LrcTextParser.getCues_(data, time.segmentEnd); + } + + /** + * @param {BufferSource} data + * @param {number} segmentEnd + * @return {!Array.} + * @private + */ + static getCues_(data, segmentEnd) { + const StringUtils = shaka.util.StringUtils; + const LrcTextParser = shaka.text.LrcTextParser; + + // Get the input as a string. + const str = StringUtils.fromUTF8(data); + + /** @type {shaka.extern.Cue} */ + let prevCue = null; + + /** @type {!Array.} */ + const cues = []; + const lines = str.split(/\r?\n/); + for (const line of lines) { + if (!line || /^\s+$/.test(line)) { + continue; + } + + // LRC content + const match = LrcTextParser.lyricLine_.exec(line); + if (match) { + const startTime = LrcTextParser.parseTime_(match[1]); + // This time can be overwritten by a subsequent cue. + // By default we add 2 seconds of duration. + const endTime = segmentEnd ? segmentEnd : startTime + 2; + const payload = match[2]; + const cue = new shaka.text.Cue(startTime, endTime, payload); + + // Update previous + if (prevCue) { + prevCue.endTime = startTime; + cues.push(prevCue); + } + prevCue = cue; + continue; + } + shaka.log.warning('LrcTextParser encountered an unknown line.', line); + } + if (prevCue) { + cues.push(prevCue); + } + + return cues; + } + + /** + * Parses a LRC time from the given parser. + * + * @param {string} string + * @return {number} + * @private + */ + static parseTime_(string) { + const LrcTextParser = shaka.text.LrcTextParser; + const match = LrcTextParser.timeFormat_.exec(string); + const minutes = parseInt(match[1], 10); + const seconds = parseFloat(match[2].replace(',', '.')); + return minutes * 60 + seconds; + } + + /** + * Convert a LRC input to WebVTT + * + * @param {BufferSource} data + * @param {number} segmentEnd + * @return {string} + * @export + */ + static convertToWebVTT(data, segmentEnd) { + const LrcTextParser = shaka.text.LrcTextParser; + const cues = LrcTextParser.getCues_(data, segmentEnd); + let webvttString = 'WEBVTT\n\n'; + for (const cue of cues) { + const webvttTimeString = (time) => { + const hours = Math.floor(time / 3600); + const minutes = Math.floor(time / 60 % 60); + const seconds = Math.floor(time % 60); + const milliseconds = Math.floor(time * 1000 % 1000); + return (hours < 10 ? '0' : '') + hours + ':' + + (minutes < 10 ? '0' : '') + minutes + ':' + + (seconds < 10 ? '0' : '') + seconds + '.' + + (milliseconds < 100 ? (milliseconds < 10 ? '00' : '0') : '') + + milliseconds; + }; + webvttString += webvttTimeString(cue.startTime) + ' --> ' + + webvttTimeString(cue.endTime) + '\n'; + webvttString += cue.payload + '\n\n'; + } + return webvttString; + } +}; + +/** + * @const + * @private {!RegExp} + * @example [00:12.0]Text or [00:12.00]Text or [00:12.000]Text or + * [00:12,0]Text or [00:12,00]Text or [00:12,000]Text + */ +shaka.text.LrcTextParser.lyricLine_ = + /^\[(\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\](.*)/; + +/** + * @const + * @private {!RegExp} + * @example 00:12.0 or 00:12.00 or 00:12.000 or + * 00:12,0 or 00:12,00 or 00:12,000 + */ +shaka.text.LrcTextParser.timeFormat_ = + /^(\d+):(\d{1,2}(?:[.,]\d{1,3})?)$/; + +shaka.text.TextEngine.registerParser( + 'application/x-subtitle-lrc', () => new shaka.text.LrcTextParser()); diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js index 7c4a3a47c1..f5a9af599e 100644 --- a/shaka-player.uncompiled.js +++ b/shaka-player.uncompiled.js @@ -49,6 +49,7 @@ goog.require('shaka.polyfill.VideoPlaybackQuality'); goog.require('shaka.polyfill'); goog.require('shaka.routing.Walker'); goog.require('shaka.text.Cue'); +goog.require('shaka.text.LrcTextParser'); goog.require('shaka.text.Mp4TtmlParser'); goog.require('shaka.text.Mp4VttParser'); goog.require('shaka.text.TextEngine'); diff --git a/test/text/lrc_text_parser_unit.js b/test/text/lrc_text_parser_unit.js new file mode 100644 index 0000000000..70e87f207d --- /dev/null +++ b/test/text/lrc_text_parser_unit.js @@ -0,0 +1,104 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.require('shaka.text.LrcTextParser'); +goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.StringUtils'); + +describe('LrcTextParser', () => { + it('supports no cues', () => { + verifyHelper([], + '', + {periodStart: 0, segmentStart: 0, segmentEnd: 0}); + }); + + it('handles a blank line at the start of the file', () => { + verifyHelper( + [ + {startTime: 0, endTime: 2, payload: 'Test'}, + ], + '\n\n' + + '[00:00.00]Test', + {periodStart: 0, segmentStart: 0, segmentEnd: 0}); + }); + + it('handles a blank line at the end of the file', () => { + verifyHelper( + [ + {startTime: 0, endTime: 2, payload: 'Test'}, + ], + '[00:00.00]Test' + + '\n\n', + {periodStart: 0, segmentStart: 0, segmentEnd: 0}); + }); + + it('handles no blank line at the end of the file', () => { + verifyHelper( + [ + {startTime: 0, endTime: 2, payload: 'Test'}, + ], + '[00:00.00]Test', + {periodStart: 0, segmentStart: 0, segmentEnd: 0, + }); + }); + + it('supports multiple cues', () => { + verifyHelper( + [ + {startTime: 0, endTime: 10, payload: 'Test'}, + {startTime: 10, endTime: 20, payload: 'Test2'}, + {startTime: 20, endTime: 22, payload: 'Test3'}, + ], + '[00:00.00]Test\n' + + '[00:10.00]Test2\n' + + '[00:20.00]Test3', + {periodStart: 0, segmentStart: 0, segmentEnd: 0}); + }); + + it('supports different time formats', () => { + verifyHelper( + [ + {startTime: 0.1, endTime: 10.001, payload: 'Test'}, + {startTime: 10.001, endTime: 20.02, payload: 'Test2'}, + {startTime: 20.02, endTime: 30.1, payload: 'Test3'}, + {startTime: 30.1, endTime: 40.001, payload: 'Test4'}, + {startTime: 40.001, endTime: 50.02, payload: 'Test5'}, + {startTime: 50.02, endTime: 52.02, payload: 'Test6'}, + ], + '[00:00.1]Test\n' + + '[00:10.001]Test2\n' + + '[00:20.02]Test3\n' + + '[00:30,1]Test4\n' + + '[00:40,001]Test5\n' + + '[00:50,02]Test6', + {periodStart: 0, segmentStart: 0, segmentEnd: 0}); + }); + + /** + * @param {!Array} cues + * @param {string} text + * @param {shaka.extern.TextParser.TimeContext} time + */ + function verifyHelper(cues, text, time) { + const BufferUtils = shaka.util.BufferUtils; + const StringUtils = shaka.util.StringUtils; + + const data = BufferUtils.toUint8(StringUtils.toUTF8(text)); + + const parser = new shaka.text.LrcTextParser(); + const result = parser.parseMedia(data, time); + + const expected = cues.map((cue) => { + if (cue.nestedCues) { + cue.nestedCues = cue.nestedCues.map( + (nestedCue) => jasmine.objectContaining(nestedCue) + ); + } + return jasmine.objectContaining(cue); + }); + expect(result).toEqual(expected); + } +});