Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions build/types/text
Original file line number Diff line number Diff line change
@@ -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
Expand Down
38 changes: 27 additions & 11 deletions lib/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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.<string>}
* @return {!Promise.<BufferSource>}
* @private
*/
async getTextData_(uri, netEngine, retryParams) {
Expand All @@ -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
Expand All @@ -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,
Expand Down
156 changes: 156 additions & 0 deletions lib/text/lrc_text_parser.js
Original file line number Diff line number Diff line change
@@ -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.<!shaka.extern.Cue>}
* @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.<!shaka.extern.Cue>} */
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());
1 change: 1 addition & 0 deletions shaka-player.uncompiled.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
104 changes: 104 additions & 0 deletions test/text/lrc_text_parser_unit.js
Original file line number Diff line number Diff line change
@@ -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);
}
});