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/package-lock.json b/package-lock.json index 88911b13..bf98f485 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "dashjs": "github:bbc/dash.js#smp-v4.7.3-6", + "dashjs": "github:bbc/dash.js#smp-v4.7.3-7", "smp-imsc": "github:bbc/imscJS#v1.0.3" }, "devDependencies": { @@ -18,7 +18,7 @@ "@babel/plugin-transform-runtime": "^7.23.9", "@babel/preset-env": "^7.23.8", "@babel/preset-typescript": "^7.23.3", - "@rollup/plugin-alias": "^5.1.0", + "@rollup/plugin-alias": "^5.1.1", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-inject": "^5.0.5", @@ -2848,13 +2848,11 @@ } }, "node_modules/@rollup/plugin-alias": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-5.1.0.tgz", - "integrity": "sha512-lpA3RZ9PdIG7qqhEfv79tBffNaoDuukFDrmhLqg9ifv99u/ehn+lOg30x2zmhf8AQqQUZaMk/B9fZraQ6/acDQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-5.1.1.tgz", + "integrity": "sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==", "dev": true, - "dependencies": { - "slash": "^4.0.0" - }, + "license": "MIT", "engines": { "node": ">=14.0.0" }, @@ -2867,18 +2865,6 @@ } } }, - "node_modules/@rollup/plugin-alias/node_modules/slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@rollup/plugin-babel": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.0.4.tgz", @@ -5620,7 +5606,7 @@ }, "node_modules/dashjs": { "version": "4.7.3", - "resolved": "git+ssh://git@github.com/bbc/dash.js.git#84ad9d4d3abc3212dd6951bf44961905fc82f672", + "resolved": "git+ssh://git@github.com/bbc/dash.js.git#8b7c98db6c79135253975d3ff805124b66674092", "license": "BSD-3-Clause", "dependencies": { "bcp-47-match": "^2.0.3", @@ -8248,9 +8234,10 @@ } }, "node_modules/imsc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/imsc/-/imsc-1.1.4.tgz", - "integrity": "sha512-s/WbXG6IbeW6X/8sBJWcQD22mwRcnpI55b8Kr3sbcONUaeMLkpHle/PE1xcMN9HJrMc5idrCwNV7wtZ8EBsFnw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/imsc/-/imsc-1.1.5.tgz", + "integrity": "sha512-V8je+CGkcvGhgl2C1GlhqFFiUOIEdwXbXLiu1Fcubvvbo+g9inauqT3l0pNYXGoLPBj3jxtZz9t+wCopMkwadQ==", + "license": "BSD-2-Clause", "dependencies": { "sax": "1.2.1" } diff --git a/package.json b/package.json index 425e0ca5..64594fec 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@babel/plugin-transform-runtime": "^7.23.9", "@babel/preset-env": "^7.23.8", "@babel/preset-typescript": "^7.23.3", - "@rollup/plugin-alias": "^5.1.0", + "@rollup/plugin-alias": "^5.1.1", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-inject": "^5.0.5", @@ -64,7 +64,7 @@ "typescript-eslint": "^7.2.0" }, "dependencies": { - "dashjs": "github:bbc/dash.js#smp-v4.7.3-6", + "dashjs": "github:bbc/dash.js#smp-v4.7.3-7", "smp-imsc": "github:bbc/imscJS#v1.0.3" }, "repository": { diff --git a/rollup.config.js b/rollup.config.js index dfee102c..46ba6079 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,5 +1,6 @@ import PackageJSON from "./package.json" assert { type: "json" } +import alias from "@rollup/plugin-alias" import replace from "@rollup/plugin-replace" import typescript from "@rollup/plugin-typescript" import { dts } from "rollup-plugin-dts" @@ -10,6 +11,9 @@ export default [ external: [/^dashjs/, "smp-imsc", "tslib"], output: [{ dir: "dist/esm", format: "es" }], plugins: [ + alias({ + entries: [{ find: "imsc", replacement: "smp-imsc" }], + }), replace({ preventAssignment: true, __VERSION__: () => PackageJSON.version, diff --git a/rollup.dev.config.js b/rollup.dev.config.js index a541eac0..866a0fe6 100644 --- a/rollup.dev.config.js +++ b/rollup.dev.config.js @@ -1,5 +1,6 @@ import PackageJSON from "./package.json" assert { type: "json" } +import alias from "@rollup/plugin-alias" import babel from "@rollup/plugin-babel" import commonjs from "@rollup/plugin-commonjs" import resolve from "@rollup/plugin-node-resolve" @@ -20,6 +21,9 @@ export default { format: "es", }, plugins: [ + alias({ + entries: [{ find: "imsc", replacement: "smp-imsc" }], + }), replace({ preventAssignment: true, __VERSION__: () => PackageJSON.version, diff --git a/src/bigscreenplayer.js b/src/bigscreenplayer.js index 44253cd2..f5cde03e 100644 --- a/src/bigscreenplayer.js +++ b/src/bigscreenplayer.js @@ -114,13 +114,6 @@ function BigscreenPlayer() { !initialPresentationTime && initialPresentationTime !== 0 - readyHelper = ReadyHelper( - initialPresentationTime, - mediaSources.time().manifestType, - PlayerComponent.getLiveSupport(), - _callbacks.playerReady - ) - playerComponent = PlayerComponent( playbackElement, { media, enableAudioDescribed, initialPlaybackTime: initialPresentationTime }, @@ -130,13 +123,21 @@ function BigscreenPlayer() { callAudioDescribedCallbacks ) - subtitles = Subtitles( - playerComponent, - enableSubtitles, - playbackElement, - media.subtitleCustomisation, - mediaSources, - callSubtitlesCallbacks + readyHelper = ReadyHelper( + initialPresentationTime, + mediaSources.time().manifestType, + PlayerComponent.getLiveSupport(), + () => { + _callbacks.playerReady() + subtitles = Subtitles( + playerComponent, + enableSubtitles, + playbackElement, + media.subtitleCustomisation, + mediaSources, + callSubtitlesCallbacks + ) + } ) } diff --git a/src/playbackstrategy/msestrategy.js b/src/playbackstrategy/msestrategy.js index 08e7f336..df66056d 100644 --- a/src/playbackstrategy/msestrategy.js +++ b/src/playbackstrategy/msestrategy.js @@ -28,6 +28,8 @@ function MSEStrategy( let mediaPlayer let mediaElement + let subtitleElement + let subtitlesEnabled = false const manifestType = mediaSources.time().manifestType const playerSettings = Utils.merge( @@ -37,12 +39,15 @@ function MSEStrategy( }, streaming: { blacklistExpiryTime: mediaSources.failoverResetTime(), + lastMediaSettingsCachingInfo: { enabled: false }, buffer: { bufferToKeep: 4, bufferTimeAtTopQuality: 12, bufferTimeAtTopQualityLongForm: 15, }, - lastMediaSettingsCachingInfo: { enabled: false }, + text: { + defaultEnabled: false, + }, }, }, customPlayerSettings @@ -108,6 +113,7 @@ function MSEStrategy( STREAM_INITIALIZED: "streamInitialized", FRAGMENT_CONTENT_LENGTH_MISMATCH: "fragmentContentLengthMismatch", QUOTA_EXCEEDED: "quotaExceeded", + TEXT_TRACKS_ADDED: "allTextTracksAdded", CURRENT_TRACK_CHANGED: "currentTrackChanged", } @@ -555,6 +561,13 @@ function MSEStrategy( } } + function setUpSubtitleElement(playbackElement) { + subtitleElement = document.createElement("div") + subtitleElement.id = "bsp_subtitles" + subtitleElement.style.position = "absolute" + playbackElement.appendChild(subtitleElement, playbackElement.firstChild) + } + function setUpMediaElement(playbackElement) { mediaElement = mediaKind === MediaKinds.AUDIO ? document.createElement("audio") : document.createElement("video") @@ -578,6 +591,7 @@ function MSEStrategy( function setUpMediaPlayer(presentationTimeInSeconds) { const dashSettings = getDashSettings(playerSettings) + const embeddedSubs = window.bigscreenPlayer?.overrides?.embeddedSubtitles ?? false const protectionData = mediaSources.currentProtectionData() mediaPlayer = MediaPlayer().create() @@ -589,6 +603,11 @@ function MSEStrategy( mediaPlayer.initialize(mediaElement, null) + if (embeddedSubs) { + setUpSubtitleElement(playbackElement) + mediaPlayer.attachTTMLRenderingDiv(subtitleElement) + } + modifySource(presentationTimeInSeconds) } @@ -670,10 +689,15 @@ function MSEStrategy( 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, handleTextTracks) mediaPlayer.on(DashJSEvents.MANIFEST_LOADING_FINISHED, manifestLoadingFinished) mediaPlayer.on(DashJSEvents.CURRENT_TRACK_CHANGED, onCurrentTrackChanged) } + function handleTextTracks() { + mediaPlayer.enableText(subtitlesEnabled) + } + function manifestLoadingFinished(event) { manifestLoadCount++ manifestRequestTime = event.request.requestEndDate.getTime() - event.request.requestStartDate.getTime() @@ -700,6 +724,10 @@ function MSEStrategy( : { start: 0, end: getDuration() } } + function customiseSubtitles(options) { + return mediaPlayer && mediaPlayer.updateSettings({ streaming: { text: { imsc: { options } } } }) + } + function getDuration() { const duration = mediaPlayer && mediaPlayer.isReady() && mediaPlayer.duration() @@ -777,6 +805,11 @@ function MSEStrategy( ) } + function isSubtitlesAvailable() { + const textTracks = mediaPlayer.getTracksFor("text") + return (textTracks && textTracks.length > 0) ?? false + } + function isTrackAudioDescribed(track) { return ( track.roles.includes("alternate") && @@ -855,9 +888,13 @@ function MSEStrategy( mediaElement.removeEventListener("ratechange", onRateChange) DOMHelpers.safeRemoveElement(mediaElement) - mediaElement = undefined } + + if (subtitleElement) { + DOMHelpers.safeRemoveElement(subtitleElement) + subtitleElement = undefined + } } function getSafelySeekableRange() { @@ -952,9 +989,17 @@ function MSEStrategy( getCurrentTime, isAudioDescribedAvailable, isAudioDescribedEnabled, + isSubtitlesAvailable, setAudioDescribedOn, setAudioDescribedOff, getDuration, + setSubtitles: (state) => { + subtitlesEnabled = state ?? false + + if (mediaPlayer) { + mediaPlayer.enableText(subtitlesEnabled) + } + }, getPlayerElement: () => mediaElement, tearDown, reset: () => { @@ -964,6 +1009,7 @@ function MSEStrategy( }, isEnded: () => isEnded, isPaused, + customiseSubtitles, pause, play: () => mediaPlayer.play(), setCurrentTime, diff --git a/src/playbackstrategy/msestrategy.test.js b/src/playbackstrategy/msestrategy.test.js index 022e1042..2aab3d1f 100644 --- a/src/playbackstrategy/msestrategy.test.js +++ b/src/playbackstrategy/msestrategy.test.js @@ -58,6 +58,7 @@ function createMockDashInstance() { time: jest.fn(), duration: jest.fn().mockReturnValue(100), attachSource: jest.fn(), + attachTTMLRenderingDiv: jest.fn(), reset: jest.fn(), destroy: jest.fn(), isPaused: jest.fn(), @@ -1889,4 +1890,28 @@ describe("Media Source Extensions Playback Strategy", () => { expect(mockErrorCallback).toHaveBeenCalledWith({ code: 30, message: "videoCodec is not supported" }) }) }) + + describe("MSE embedded subtitles", () => { + it("Expect MSE strategy to create subtitle div when embedded subtitles is enabled", () => { + window.bigscreenPlayer = { overrides: { embeddedSubtitles: true } } + + const mseStrategy = MSEStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + mseStrategy.load(null, 0) + + const subtitlesElement = playbackElement.querySelector("#bsp_subtitles") + + expect(subtitlesElement).toBeTruthy() + }) + + it("Expect created div to have been attached to Dash.js", () => { + window.bigscreenPlayer = { overrides: { embeddedSubtitles: true } } + + const mseStrategy = MSEStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + mseStrategy.load(null, 0) + + const subtitlesElement = playbackElement.querySelector("#bsp_subtitles") + + expect(mockDashInstance.attachTTMLRenderingDiv).toHaveBeenCalledWith(subtitlesElement) + }) + }) }) diff --git a/src/playercomponent.js b/src/playercomponent.js index 0a71e160..28e85995 100644 --- a/src/playercomponent.js +++ b/src/playercomponent.js @@ -43,6 +43,7 @@ function PlayerComponent( errorCallback, audioDescribedCallback ) { + let setSubtitlesState let _stateUpdateCallback = stateUpdateCallback let mediaKind = bigscreenPlayerData.media.kind @@ -77,6 +78,8 @@ function PlayerComponent( mediaMetaData = bigscreenPlayerData.media loadMedia(bigscreenPlayerData.media.type, bigscreenPlayerData.initialPlaybackTime) + + if (setSubtitlesState) playbackStrategy.setSubtitles(setSubtitlesState) }) .catch((error) => { errorCallback && errorCallback(error) @@ -96,6 +99,22 @@ function PlayerComponent( } } + function isSubtitlesAvailable() { + return playbackStrategy && playbackStrategy.isSubtitlesAvailable() + } + + function setSubtitles(state) { + if (playbackStrategy) { + playbackStrategy.setSubtitles(state) + } else { + setSubtitlesState = state + } + } + + function customiseSubtitles(styleOpts) { + return playbackStrategy && playbackStrategy.customiseSubtitles(styleOpts) + } + function getDuration() { return playbackStrategy?.getDuration() } @@ -431,8 +450,10 @@ function PlayerComponent( return { play, pause, + customiseSubtitles, transitions, isEnded, + isSubtitlesAvailable, setPlaybackRate, getPlaybackRate, setCurrentTime, @@ -445,6 +466,7 @@ function PlayerComponent( isAudioDescribedAvailable, isAudioDescribedEnabled, setAudioDescribed, + setSubtitles, } } diff --git a/src/subtitles/embeddedsubtitles.js b/src/subtitles/embeddedsubtitles.js new file mode 100644 index 00000000..7d0314e4 --- /dev/null +++ b/src/subtitles/embeddedsubtitles.js @@ -0,0 +1,124 @@ +import { fromXML, generateISD, renderHTML } from "smp-imsc" +import DOMHelpers from "../domhelpers" +import Utils from "../utils/playbackutils" +import DebugTool from "../debugger/debugtool" +import Plugins from "../plugins" + +function EmbeddedSubtitles(mediaPlayer, autoStart, parentElement, _mediaSources, defaultStyleOpts) { + let exampleSubtitlesElement + let imscRenderOpts = transformStyleOptions(defaultStyleOpts) + let subtitlesEnabled = false + + if (autoStart) start() + + function removeExampleSubtitlesElement() { + if (exampleSubtitlesElement) { + DOMHelpers.safeRemoveElement(exampleSubtitlesElement) + exampleSubtitlesElement = undefined + } + } + + function renderExample(exampleXmlString, styleOpts, safePosition = {}) { + const exampleXml = fromXML(exampleXmlString) + removeExampleSubtitlesElement() + + const customStyleOptions = transformStyleOptions(styleOpts) + const exampleStyle = Utils.merge(imscRenderOpts, customStyleOptions) + + exampleSubtitlesElement = document.createElement("div") + exampleSubtitlesElement.id = "subtitlesPreview" + exampleSubtitlesElement.style.position = "absolute" + + const elementWidth = parentElement.clientWidth + const elementHeight = parentElement.clientHeight + const topPixels = ((safePosition.top || 0) / 100) * elementHeight + const rightPixels = ((safePosition.right || 0) / 100) * elementWidth + const bottomPixels = ((safePosition.bottom || 0) / 100) * elementHeight + const leftPixels = ((safePosition.left || 0) / 100) * elementWidth + + const renderWidth = elementWidth - leftPixels - rightPixels + const renderHeight = elementHeight - topPixels - bottomPixels + + exampleSubtitlesElement.style.top = `${topPixels}px` + exampleSubtitlesElement.style.right = `${rightPixels}px` + exampleSubtitlesElement.style.bottom = `${bottomPixels}px` + exampleSubtitlesElement.style.left = `${leftPixels}px` + parentElement.appendChild(exampleSubtitlesElement) + + renderSubtitle(exampleXml, 1, exampleSubtitlesElement, exampleStyle, renderHeight, renderWidth) + } + + function renderSubtitle(xml, currentTime, subsElement, styleOpts, renderHeight, renderWidth) { + try { + const isd = generateISD(xml, currentTime) + renderHTML(isd, subsElement, null, renderHeight, renderWidth, false, null, null, false, styleOpts) + } catch (error) { + error.name = "SubtitlesRenderError" + DebugTool.error(error) + + Plugins.interface.onSubtitlesRenderError() + } + } + + function start() { + subtitlesEnabled = true + mediaPlayer.setSubtitles(true) + mediaPlayer.customiseSubtitles(imscRenderOpts) + } + + function stop() { + subtitlesEnabled = false + mediaPlayer.setSubtitles(false) + } + + function tearDown() { + stop() + } + + function customise(styleOpts) { + const customStyleOptions = transformStyleOptions(styleOpts) + imscRenderOpts = Utils.merge(imscRenderOpts, customStyleOptions) + mediaPlayer.customiseSubtitles(imscRenderOpts) + if (subtitlesEnabled) { + stop() + start() + } + } + + // 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 + } + + return { + start, + stop, + updatePosition: () => {}, + customise, + renderExample, + clearExample: removeExampleSubtitlesElement, + tearDown, + } +} + +export default EmbeddedSubtitles diff --git a/src/subtitles/embeddedsubtitles.test.js b/src/subtitles/embeddedsubtitles.test.js new file mode 100644 index 00000000..06f59773 --- /dev/null +++ b/src/subtitles/embeddedsubtitles.test.js @@ -0,0 +1,252 @@ +import EmbeddedSubtitles from "./embeddedsubtitles" +import { fromXML, generateISD, renderHTML } from "smp-imsc" + +jest.mock("smp-imsc") + +const UPDATE_INTERVAL = 750 + +const mockImscDoc = { + getMediaTimeEvents: () => [1, 3, 8], + head: { + styling: {}, + }, + body: { + contents: [], + }, +} + +describe("Embedded Subtitles", () => { + let subtitles + let targetElement + let subtitleElement + + const mockMediaPlayer = { + getCurrentTime: jest.fn(), + setSubtitles: jest.fn(), + addEventCallback: jest.fn(), + customiseSubtitles: jest.fn(), + } + + beforeAll(() => { + fromXML.mockReturnValue(mockImscDoc) + generateISD.mockReturnValue({ contents: ["mockContents"] }) + }) + + beforeEach(() => { + jest.useFakeTimers() + jest.clearAllMocks() + jest.clearAllTimers() + + mockMediaPlayer.getCurrentTime.mockReturnValue(0) + + // Reset the target HTML element between each test + targetElement?.remove() + targetElement = document.createElement("div") + + subtitleElement?.remove() + subtitleElement = document.createElement("div") + subtitleElement.id = "bsp_subtitles" + subtitleElement.style.position = "absolute" + targetElement.appendChild(subtitleElement, targetElement.firstChild) + + 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?.stop() + + // 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), + }) + ) + }) + }) + + describe("autoplay", () => { + it("triggers the MSE player to enable subtitles immediately when autoplay is true", () => { + 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 autoplay is false", () => { + 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) + }) + + it("applies customisations immediately", () => { + const defaultStyleOpts = { backgroundColour: "black", fontFamily: "Arial" } + const customStyleOpts = { size: 0.7, lineHeight: 0.9 } + + subtitles = EmbeddedSubtitles(mockMediaPlayer, false, targetElement, null, defaultStyleOpts) + + mockMediaPlayer.getCurrentTime.mockReturnValueOnce(1) + + subtitles.start() + expect(mockMediaPlayer.setSubtitles).toHaveBeenCalledWith(true) + + mockMediaPlayer.setSubtitles.mockReset() + + subtitles.customise(customStyleOpts) + + expect(mockMediaPlayer.setSubtitles).toHaveBeenNthCalledWith(1, false) + expect(mockMediaPlayer.setSubtitles).toHaveBeenNthCalledWith(2, true) + }) + }) + + describe("example rendering", () => { + it("should call fromXML, generate and render when renderExample is called", () => { + subtitles = EmbeddedSubtitles(mockMediaPlayer, false, targetElement, null, {}) + + subtitles.renderExample("", {}, {}) + + expect(fromXML).toHaveBeenCalledTimes(1) + expect(generateISD).toHaveBeenCalledTimes(1) + expect(renderHTML).toHaveBeenCalledTimes(1) + }) + + it("should call renderHTML with a preview element with the correct structure when no position info", () => { + subtitles = EmbeddedSubtitles(mockMediaPlayer, false, targetElement, null, {}) + + let exampleSubsElement = null + let height = null + let width = null + + renderHTML.mockImplementation((isd, subsElement, _, renderHeight, renderWidth) => { + exampleSubsElement = subsElement + height = renderHeight + width = renderWidth + }) + + subtitles.renderExample("", {}, {}) + + expect(renderHTML).toHaveBeenCalledTimes(1) + + expect(exampleSubsElement.style.top).toBe("0px") + expect(exampleSubsElement.style.right).toBe("0px") + expect(exampleSubsElement.style.bottom).toBe("0px") + expect(exampleSubsElement.style.left).toBe("0px") + + expect(height).toBe(100) + expect(width).toBe(200) + }) + + it("should call renderHTML with a preview element with the correct structure when there is position info", () => { + subtitles = EmbeddedSubtitles(mockMediaPlayer, false, targetElement, null, {}) + + let exampleSubsElement = null + let height = null + let width = null + + renderHTML.mockImplementation((isd, subsElement, _, renderHeight, renderWidth) => { + exampleSubsElement = subsElement + height = renderHeight + width = renderWidth + }) + + subtitles.renderExample( + "", + {}, + { + top: 1, + right: 2, + bottom: 3, + left: 4, + } + ) + + expect(renderHTML).toHaveBeenCalledTimes(1) + + expect(exampleSubsElement.style.top).toBe("1px") + expect(exampleSubsElement.style.right).toBe("4px") + expect(exampleSubsElement.style.bottom).toBe("3px") + expect(exampleSubsElement.style.left).toBe("8px") + + expect(height).toBe(96) + expect(width).toBe(188) + }) + }) +}) diff --git a/src/subtitles/subtitles.js b/src/subtitles/subtitles.js index 1ffff7a4..54571b79 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 mediaPlayer && mediaPlayer.isSubtitlesAvailable() + } + 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..76f0580a 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,48 @@ 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 = { + isSubtitlesAvailable: jest.fn(() => true), + } + + 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 = { + isSubtitlesAvailable: jest.fn(() => true), + } + + 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 = {}