Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
56 changes: 45 additions & 11 deletions lib/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -3871,6 +3871,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 @@ -4004,6 +4005,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 @@ -4034,10 +4036,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 @@ -4131,7 +4133,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 @@ -4142,14 +4144,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 @@ -4159,13 +4161,45 @@ 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) {
const cues = LrcTextParser.getCues(data);
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;
} 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
121 changes: 121 additions & 0 deletions lib/text/lrc_text_parser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*! @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');


/**
* @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);
}

/**
* @param {BufferSource} data
* @return {!Array.<!shaka.extern.Cue>}
* @export
*/
static getCues(data) {
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 parts = str.split(/\r?\n/);
for (const part of parts) {
if (!part || /^\s+$/.test(part)) {
continue;
}

// LRC content
const match = LrcTextParser.lyricLine_.exec(part);
if (match) {
const startTime = LrcTextParser.parseTime_(match[1]);
// By default we add 2 seconds of duration.
const endTime = startTime + 2;
const payload = match[3];
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 parser encountered an unknown part.',
part);
}
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 = parseInt(match[2], 10);
const hundredthsOfSeconds = match[4] ? parseInt(match[4], 10) : 0;
return minutes * 60 + seconds + hundredthsOfSeconds / 100;
}
};

/**
* @const
* @private {!RegExp}
* @example 50t or 50.5t
*/
shaka.text.LrcTextParser.lyricLine_ =
/^\[(\d{1,2}:\d{1,2}([.,]\d{1,3})?)\](.*)(\r?\n)*$/;

/**
* @const
* @private {!RegExp}
* @example 50t or 50.5t
*/
shaka.text.LrcTextParser.timeFormat_ =
/^\s*(\d+):(\d{1,2})([.,](\d{1,3}))?\s*$/;

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
85 changes: 85 additions & 0 deletions test/text/lrc_text_parser_unit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*! @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});
});

/**
* @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);
}
});