diff --git a/docs/tutorials/Subtitles.md b/docs/tutorials/Subtitles.md index f84811b7..f656322d 100644 --- a/docs/tutorials/Subtitles.md +++ b/docs/tutorials/Subtitles.md @@ -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. @@ -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. @@ -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: @@ -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? diff --git a/src/playbackstrategy/msestrategy.js b/src/playbackstrategy/msestrategy.js index 2c54694b..30309642 100644 --- a/src/playbackstrategy/msestrategy.js +++ b/src/playbackstrategy/msestrategy.js @@ -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() { @@ -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) } @@ -525,7 +532,6 @@ function MSEStrategy(mediaSources, windowType, mediaKind, playbackElement, isUHD windowType, initialSeekableRangeStartSeconds: mediaSources.time().windowStartTime / 1000, }) - mediaPlayer.attachSource(`${source}${anchor}`) } @@ -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() { + 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() @@ -587,6 +608,10 @@ function MSEStrategy(mediaSources, windowType, mediaKind, playbackElement, isUHD } } + function customiseSubtitles(options) { + return mediaPlayer && mediaPlayer.updateSettings({ streaming: { text: { imsc: { options } } } }) + } + function getDuration() { return mediaPlayer && mediaPlayer.isReady() ? mediaPlayer.duration() : 0 } @@ -711,6 +736,12 @@ function MSEStrategy(mediaSources, windowType, mediaKind, playbackElement, isUHD getSeekableRange, getCurrentTime, getDuration, + setSubtitles: (state) => { + if (state) { + enableTextTracks() + } + mediaPlayer.enableText(state) + }, getPlayerElement: () => mediaElement, tearDown: () => { cleanUpMediaPlayer() @@ -750,6 +781,7 @@ function MSEStrategy(mediaSources, windowType, mediaKind, playbackElement, isUHD startAutoResumeTimeout() } }, + customiseSubtitles, play: () => mediaPlayer.play(), setCurrentTime: (time) => { publishedSeekEvent = false diff --git a/src/playercomponent.js b/src/playercomponent.js index 54784ace..a32c8854 100644 --- a/src/playercomponent.js +++ b/src/playercomponent.js @@ -71,6 +71,14 @@ function PlayerComponent( } } + function setSubtitles(state) { + return playbackStrategy && playbackStrategy.setSubtitles(state) + } + + function customiseSubtitles(styleOpts) { + return playbackStrategy && playbackStrategy.customiseSubtitles(styleOpts) + } + function getDuration() { return playbackStrategy && playbackStrategy.getDuration() } @@ -394,6 +402,8 @@ function PlayerComponent( return { play, pause, + setSubtitles, + customiseSubtitles, transitions, isEnded, setPlaybackRate, diff --git a/src/subtitles/embeddedsubtitles.js b/src/subtitles/embeddedsubtitles.js new file mode 100644 index 00000000..2171f8bd --- /dev/null +++ b/src/subtitles/embeddedsubtitles.js @@ -0,0 +1,90 @@ +import DOMHelpers from "../domhelpers" +import Utils from "../utils/playbackutils" + +function EmbeddedSubtitles(mediaPlayer, autoStart, parentElement, mediaSources, defaultStyleOpts) { + let currentSubtitlesElement + + let imscRenderOpts = transformStyleOptions(defaultStyleOpts) + + if (autoStart) { + start() + mediaPlayer.addEventCallback(this, onMediaPlayerReady) + } + + function onMediaPlayerReady() { + 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) + customise(imscRenderOpts) + if (!currentSubtitlesElement) { + addCurrentSubtitlesElement() + } + } + + function stop() { + mediaPlayer.setSubtitles(false) + } + + function tearDown() { + stop() + } + + function customise(styleOpts) { + const customStyleOptions = transformStyleOptions(styleOpts) + imscRenderOpts = Utils.merge(imscRenderOpts, customStyleOptions) + mediaPlayer.customiseSubtitles(imscRenderOpts) + } + + // Opts: { backgroundColour: string (css colour, hex), fontFamily: string , size: number, lineHeight: number } + function transformStyleOptions(opts) { + if (opts === undefined) return + + const customStyles = {} + + if (opts.backgroundColour) { + customStyles.spanBackgroundColorAdjust = { transparent: opts.backgroundColour } + } + + if (opts.fontFamily) { + customStyles.fontFamily = opts.fontFamily + } + + if (opts.size > 0) { + customStyles.sizeAdjust = opts.size + } + + if (opts.lineHeight) { + customStyles.lineHeightAdjust = opts.lineHeight + } + + return customStyles + } + + addCurrentSubtitlesElement() + + return { + start, + stop, + customise, + tearDown, + } +} + +export default EmbeddedSubtitles diff --git a/src/subtitles/embeddedsubtitles.test.js b/src/subtitles/embeddedsubtitles.test.js new file mode 100644 index 00000000..e7d315bc --- /dev/null +++ b/src/subtitles/embeddedsubtitles.test.js @@ -0,0 +1,137 @@ +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(), + customiseSubtitles: 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, null, {}) + + 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, null, {}) + + progressTime(1.5) + expect(targetElement.querySelector("#bsp_subtitles")).toBeTruthy() + }) + }) + + describe("autoplay", () => { + it("triggers the MSE player to enable subtitles immediately when set to autoplay", () => { + const autoStart = true + + subtitles = EmbeddedSubtitles(mockMediaPlayer, autoStart, targetElement, null, {}) + + 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, null, {}) + + progressTime(1.5) + expect(mockMediaPlayer.setSubtitles).toHaveBeenCalledTimes(0) + }) + }) + + describe("customisation", () => { + it("overrides the subtitles styling metadata with supplied defaults when rendering", () => { + const expectedStyles = { spanBackgroundColorAdjust: { transparent: "black" }, fontFamily: "Arial" } + + subtitles = EmbeddedSubtitles(mockMediaPlayer, false, targetElement, null, { + backgroundColour: "black", + fontFamily: "Arial", + }) + + subtitles.start() + + progressTime(1) + + expect(mockMediaPlayer.customiseSubtitles).toHaveBeenCalledWith(expectedStyles) + }) + + it("overrides the subtitles styling metadata with supplied custom styles when rendering", () => { + subtitles = EmbeddedSubtitles(mockMediaPlayer, false, targetElement, null, {}) + + const styleOpts = { size: 0.7, lineHeight: 0.9 } + const expectedOpts = { sizeAdjust: 0.7, lineHeightAdjust: 0.9 } + + mockMediaPlayer.getCurrentTime.mockReturnValueOnce(1) + + subtitles.start() + subtitles.customise(styleOpts) + + expect(mockMediaPlayer.customiseSubtitles).toHaveBeenCalledWith(expectedOpts) + }) + + it("merges the current subtitles styling metadata with new supplied custom styles when rendering", () => { + const defaultStyleOpts = { backgroundColour: "black", fontFamily: "Arial" } + const customStyleOpts = { size: 0.7, lineHeight: 0.9 } + const expectedOpts = { + spanBackgroundColorAdjust: { transparent: "black" }, + fontFamily: "Arial", + sizeAdjust: 0.7, + lineHeightAdjust: 0.9, + } + + subtitles = EmbeddedSubtitles(mockMediaPlayer, false, targetElement, null, defaultStyleOpts) + + mockMediaPlayer.getCurrentTime.mockReturnValueOnce(1) + + subtitles.start() + subtitles.customise(customStyleOpts) + + expect(mockMediaPlayer.customiseSubtitles).toHaveBeenCalledWith(expectedOpts) + }) + }) +}) diff --git a/src/subtitles/subtitles.js b/src/subtitles/subtitles.js index 1ffff7a4..b1a102f9 100644 --- a/src/subtitles/subtitles.js +++ b/src/subtitles/subtitles.js @@ -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" @@ -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 }) => { @@ -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 !== "")) { diff --git a/src/subtitles/subtitles.test.js b/src/subtitles/subtitles.test.js index 250a78dc..39dac1b6 100644 --- a/src/subtitles/subtitles.test.js +++ b/src/subtitles/subtitles.test.js @@ -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 @@ -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 = {}