Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Dash.js rendered subtitles #352

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
28 changes: 22 additions & 6 deletions docs/tutorials/Subtitles.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ You provide subtitles to BigscreenPlayer by setting `media.captions` in the `.in

```js
// 1️⃣ Add an array of caption blocks to your playback data.
playbackData.media.captions = [/* caption blocks... */];
playbackData.media.captions = [
/* caption blocks... */
]

// 2️⃣ Pass playback data that contains captions to the player.
player.init(document.querySelector("video"), playbackData, /* other opts */);
player.init(document.querySelector("video"), playbackData /* other opts */)
```

1. `media.captions` MUST be an array containing at least one object.
Expand All @@ -34,7 +36,7 @@ const captions = [
{ url: "https://some.cdn/subtitles.xml" },
{ url: "https://other.cdn/subtitles.xml" },
/* ... */
];
]
```

Subtitles delivered as a whole do not require any additional metadata in the manifest to work.
Expand All @@ -49,13 +51,15 @@ const captions = [
{
url: "https://some.cdn/subtitles/$segment$.m4s",
segmentLength: 3.84,
cdn: "default",
},
{
url: "https://other.cdn/subtitles/$segment$.m4s",
segmentLength: 3.84,
cdn: "default",
},
/* ... */
];
]
```

The segment number is calculated from the presentation timeline. You MUST ensure your subtitle segments are enumerated to match your media segments and you account for offsets such as:
Expand All @@ -73,12 +77,24 @@ You can style the subtitles by setting `media.subtitleCustomisation` in the `.in

```js
// 1️⃣ Create an object mapping out styles for your subtitles.
playbackData.media.subtitleCustomisation = { lineHeight: 1.5, size: 1 };
playbackData.media.subtitleCustomisation = { lineHeight: 1.5, size: 1 }

// 2️⃣ Pass playback data that contains subtitle customisation (and captions) to the player.
player.init(document.querySelector("video"), playbackData, /* other opts */);
player.init(document.querySelector("video"), playbackData /* other opts */)
```

### Low Latency Streams

When using Dash.js with a low-latency MPD segments are delivered using Chunked Transfer Encoding (CTE) - the default side chain doesn't allow for delivery in this case.

Whilst it is possible to collect chunks as they are delivered, wait until a full segment worth of subtitles have been delivered and pass these to the render function this breaks the low-latency workflow.

An override has been added to allow subtitles to be rendered directly by Dash.js instead of the current side-chain.

Subtitles can be enabled and disabled in the usual way using the `setSubtitlesEnabled()` function. However, they are signalled and delivered by the chosen MPD.

Using Dash.js subtitles can be enabled using `window.bigscreenPlayer.overrides.embeddedSubtitles = true`.

##  Design

### Why not include subtitles in the manifest?
Expand Down
29 changes: 28 additions & 1 deletion src/playbackstrategy/msestrategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ function MSEStrategy(mediaSources, windowType, mediaKind, playbackElement, isUHD
STREAM_INITIALIZED: "streamInitialized",
FRAGMENT_CONTENT_LENGTH_MISMATCH: "fragmentContentLengthMismatch",
QUOTA_EXCEEDED: "quotaExceeded",
TEXT_TRACKS_ADDED: "allTextTracksAdded",
}

function onLoadedMetaData() {
Expand Down Expand Up @@ -512,10 +513,16 @@ function MSEStrategy(mediaSources, windowType, mediaKind, playbackElement, isUHD

function setUpMediaPlayer(playbackTime) {
const dashSettings = getDashSettings(playerSettings)
const embeddedSubs = window.bigscreenPlayer?.overrides?.embeddedSubtitles ?? false

mediaPlayer = MediaPlayer().create()
mediaPlayer.updateSettings(dashSettings)
mediaPlayer.initialize(mediaElement, null, true)

if (embeddedSubs) {
mediaPlayer.attachTTMLRenderingDiv(document.querySelector("#bsp_subtitles"))
}

modifySource(playbackTime)
}

Expand All @@ -525,7 +532,6 @@ function MSEStrategy(mediaSources, windowType, mediaKind, playbackElement, isUHD
windowType,
initialSeekableRangeStartSeconds: mediaSources.time().windowStartTime / 1000,
})

mediaPlayer.attachSource(`${source}${anchor}`)
}

Expand Down Expand Up @@ -562,9 +568,24 @@ function MSEStrategy(mediaSources, windowType, mediaKind, playbackElement, isUHD
mediaPlayer.on(DashJSEvents.GAP_JUMP, onGapJump)
mediaPlayer.on(DashJSEvents.GAP_JUMP_TO_END, onGapJump)
mediaPlayer.on(DashJSEvents.QUOTA_EXCEEDED, onQuotaExceeded)
mediaPlayer.on(DashJSEvents.TEXT_TRACKS_ADDED, disableTextTracks)
mediaPlayer.on(DashJSEvents.MANIFEST_LOADING_FINISHED, manifestLoadingFinished)
}

function disableTextTracks() {
eirikbjornr marked this conversation as resolved.
Show resolved Hide resolved
const textTracks = mediaElement.textTracks
for (let index = 0; index < textTracks.length; index++) {
textTracks[index].mode = "disabled"
}
}

function enableTextTracks() {
const textTracks = mediaElement.textTracks
for (let index = 0; index < textTracks.length; index++) {
textTracks[index].mode = "showing"
}
}

function manifestLoadingFinished(event) {
manifestLoadCount++
manifestRequestTime = event.request.requestEndDate.getTime() - event.request.requestStartDate.getTime()
Expand Down Expand Up @@ -711,6 +732,12 @@ function MSEStrategy(mediaSources, windowType, mediaKind, playbackElement, isUHD
getSeekableRange,
getCurrentTime,
getDuration,
setSubtitles: (state) => {
if (state) {
enableTextTracks()
}
mediaPlayer.enableText(state)
},
getPlayerElement: () => mediaElement,
tearDown: () => {
cleanUpMediaPlayer()
Expand Down
5 changes: 5 additions & 0 deletions src/playercomponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ function PlayerComponent(
}
}

function setSubtitles(state) {
return playbackStrategy && playbackStrategy.setSubtitles(state)
}

function getDuration() {
return playbackStrategy && playbackStrategy.getDuration()
}
Expand Down Expand Up @@ -394,6 +398,7 @@ function PlayerComponent(
return {
play,
pause,
setSubtitles,
transitions,
isEnded,
setPlaybackRate,
Expand Down
55 changes: 55 additions & 0 deletions src/subtitles/embeddedsubtitles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import DOMHelpers from "../domhelpers"

function EmbeddedSubtitles(mediaPlayer, autoStart, parentElement) {
let currentSubtitlesElement

if (autoStart) {
mediaPlayer.addEventCallback(this, onMediaPlayerReady)
}

function onMediaPlayerReady() {
start()
mediaPlayer.removeEventCallback(this, onMediaPlayerReady)
}

function removeCurrentSubtitlesElement() {
if (currentSubtitlesElement) {
DOMHelpers.safeRemoveElement(currentSubtitlesElement)
currentSubtitlesElement = undefined
}
}

function addCurrentSubtitlesElement() {
removeCurrentSubtitlesElement()
currentSubtitlesElement = document.createElement("div")
currentSubtitlesElement.id = "bsp_subtitles"
currentSubtitlesElement.style.position = "absolute"
parentElement.appendChild(currentSubtitlesElement)
}

function start() {
mediaPlayer.setSubtitles(true)
if (!currentSubtitlesElement) {
addCurrentSubtitlesElement()
}
}

function stop() {
mediaPlayer.setSubtitles(false)
}

function tearDown() {
stop()
}

addCurrentSubtitlesElement()

return {
start,
stop,
customise: () => {},
tearDown,
}
}

export default EmbeddedSubtitles
85 changes: 85 additions & 0 deletions src/subtitles/embeddedsubtitles.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import EmbeddedSubtitles from "./embeddedsubtitles"

const UPDATE_INTERVAL = 750

describe("Embedded Subtitles", () => {
let subtitles
let targetElement

const mockMediaPlayer = {
getCurrentTime: jest.fn(),
setSubtitles: jest.fn(),
addEventCallback: jest.fn(),
}

beforeEach(() => {
jest.useFakeTimers()
jest.clearAllMocks()
jest.clearAllTimers()

// Reset the target HTML element between each test
targetElement?.remove()
targetElement = document.createElement("div")

jest.spyOn(targetElement, "clientWidth", "get").mockReturnValue(200)
jest.spyOn(targetElement, "clientHeight", "get").mockReturnValue(100)
jest.spyOn(targetElement, "removeChild")

document.body.appendChild(targetElement)

// Reset instance
subtitles?.tearDown()
subtitles = null
mockMediaPlayer.setSubtitles.mockClear()
})

function progressTime(mediaPlayerTime) {
mockMediaPlayer.getCurrentTime.mockReturnValue(mediaPlayerTime)
jest.advanceTimersByTime(UPDATE_INTERVAL)
}

describe("construction", () => {
it("returns the correct interface", () => {
const autoStart = false

subtitles = EmbeddedSubtitles(mockMediaPlayer, autoStart, targetElement)

expect(subtitles).toEqual(
expect.objectContaining({
start: expect.any(Function),
stop: expect.any(Function),
customise: expect.any(Function),
tearDown: expect.any(Function),
})
)
})

it("Expect TTML rendering div to have been created", () => {
const autoStart = false
subtitles = EmbeddedSubtitles(mockMediaPlayer, autoStart, targetElement)

progressTime(1.5)
expect(targetElement.querySelector("#bsp_subtitles")).toBeTruthy()
})
})

describe("autoplay", () => {
it.skip("triggers the MSE player to enable subtitles immediately when set to autoplay", () => {
const autoStart = true

subtitles = EmbeddedSubtitles(mockMediaPlayer, autoStart, targetElement)

progressTime(1.5)
expect(mockMediaPlayer.setSubtitles).toHaveBeenCalledTimes(1)
})

it("does not trigger the MSE player to enable subtitles immediately when set to autoplay", () => {
const autoStart = false

subtitles = EmbeddedSubtitles(mockMediaPlayer, autoStart, targetElement)

progressTime(1.5)
expect(mockMediaPlayer.setSubtitles).toHaveBeenCalledTimes(0)
})
})
})
21 changes: 21 additions & 0 deletions src/subtitles/subtitles.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import findSegmentTemplate from "../utils/findtemplate"

function Subtitles(mediaPlayer, autoStart, playbackElement, defaultStyleOpts, mediaSources, callback) {
const useLegacySubs = window.bigscreenPlayer?.overrides?.legacySubtitles ?? false
const embeddedSubs = window.bigscreenPlayer?.overrides?.embeddedSubtitles ?? false

const isSeekableLiveSupport =
window.bigscreenPlayer.liveSupport == null || window.bigscreenPlayer.liveSupport === "seekable"

Expand All @@ -19,6 +21,21 @@ function Subtitles(mediaPlayer, autoStart, playbackElement, defaultStyleOpts, me
.catch(() => {
Plugins.interface.onSubtitlesDynamicLoadError()
})
} else if (embeddedSubs) {
import("./embeddedsubtitles.js")
.then(({ default: EmbeddedSubtitles }) => {
subtitlesContainer = EmbeddedSubtitles(
mediaPlayer,
autoStart,
playbackElement,
mediaSources,
defaultStyleOpts
)
callback(subtitlesEnabled)
})
.catch(() => {
Plugins.interface.onSubtitlesDynamicLoadError()
})
} else {
import("./imscsubtitles.js")
.then(({ default: IMSCSubtitles }) => {
Expand Down Expand Up @@ -67,6 +84,10 @@ function Subtitles(mediaPlayer, autoStart, playbackElement, defaultStyleOpts, me
}

function available() {
if (embeddedSubs) {
return true
}

const url = mediaSources.currentSubtitlesSource()

if (!(typeof url === "string" && url !== "")) {
Expand Down
39 changes: 39 additions & 0 deletions src/subtitles/subtitles.test.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
/* eslint-disable jest/no-done-callback */
import IMSCSubtitles from "./imscsubtitles"
import LegacySubtitles from "./legacysubtitles"
import EmbeddedSubtitles from "./embeddedsubtitles"

import Subtitles from "./subtitles"

jest.mock("./imscsubtitles")
jest.mock("./legacysubtitles")
jest.mock("./embeddedsubtitles")

describe("Subtitles", () => {
let isAvailable
Expand Down Expand Up @@ -74,6 +77,42 @@ describe("Subtitles", () => {
})
})

describe("embedded", () => {
beforeEach(() => {
window.bigscreenPlayer = {
overrides: {
embeddedSubtitles: true,
},
}

EmbeddedSubtitles.mockReset()
})

it("implementation is available when embedded subtitles override is true", (done) => {
const mockMediaPlayer = {}
const autoStart = true

Subtitles(mockMediaPlayer, autoStart, playbackElement, null, mockMediaSources, (result) => {
expect(result).toBe(true)
expect(EmbeddedSubtitles).toHaveBeenCalledTimes(1)
done()
})
})

it("implementation is available when embedded subtitles override is true, even if segmented URL is passed", (done) => {
isSegmented = true
const mockMediaPlayer = {}
const autoStart = true

Subtitles(mockMediaPlayer, autoStart, playbackElement, null, mockMediaSources, () => {
expect(LegacySubtitles).not.toHaveBeenCalled()
expect(IMSCSubtitles).not.toHaveBeenCalled()
expect(EmbeddedSubtitles).toHaveBeenCalledTimes(1)
done()
})
})
})

describe("imscjs", () => {
it("implementation is available when legacy subtitles override is false", (done) => {
const mockMediaPlayer = {}
Expand Down