diff --git a/gulpfile.js b/gulpfile.js index 11c2a14f..9ce9ff77 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -232,6 +232,7 @@ function makeBrowserifyTask(name) { const playerBrowserifyTask = parallel( makeBrowserifyTask("player"), + makeBrowserifyTask("narrated"), makeBrowserifyTask("presenter") ); @@ -266,11 +267,13 @@ function makeTemplateTask(target, tpl) { const electronTemplatesTask = parallel( makeTemplateTask("electron", "player"), + makeTemplateTask("electron", "narrated"), makeTemplateTask("electron", "presenter") ); const browserTemplatesTask = parallel( makeTemplateTask("browser", "player"), + makeTemplateTask("browser", "narrated"), makeTemplateTask("browser", "presenter") ); diff --git a/src/js/Storage.js b/src/js/Storage.js index 70f00992..d162224a 100644 --- a/src/js/Storage.js +++ b/src/js/Storage.js @@ -275,9 +275,11 @@ export class Storage { const svgName = this.backend.getName(this.svgFileDescriptor); const htmlFileName = replaceFileExtWith(svgName, ".sozi.html"); const presenterFileName = replaceFileExtWith(svgName, "-presenter.sozi.html"); + const narratedFileName = replaceFileExtWith(svgName, ".narrated.sozi.html"); // TODO Save only if SVG is more recent than HTML. this.createHTMLFile(htmlFileName, location); this.createPresenterHTMLFile(presenterFileName, location, htmlFileName); + this.createNarratedHTMLFile(narratedFileName, location, htmlFileName); } /** Create the presentation HTML file if it does not exist. @@ -312,10 +314,30 @@ export class Storage { async createPresenterHTMLFile(name, location, htmlFileName) { try { const fileDescriptor = await this.backend.find(name, location); - this.backend.save(fileDescriptor, this.exportPresenterHTML(htmlFileName)); + await this.backend.save(fileDescriptor, this.exportPresenterHTML(htmlFileName)); } catch (err) { - this.backend.create(name, location, "text/html", this.exportPresenterHTML(htmlFileName)); + await this.backend.create(name, location, "text/html", this.exportPresenterHTML(htmlFileName)); + } + } + + /** Create the narrated HTML file if it does not exist. + * + * @param {string} name - The name of the HTML file to create. + * @param {any} location - The location of the file (backend-dependent). + * @param {string} htmlFileName - The name of the presentation HTML file. + */ + async createNarratedHTMLFile(name, location, htmlFileName) { + const t = this.presentation.narrativeType; + if (!t || t === "none") { + return; + } + try { + const fileDescriptor = await this.backend.find(name, location); + await this.backend.save(fileDescriptor, this.exportNarratedHTML(htmlFileName)); + } + catch (err) { + await this.backend.create(name, location, "text/html", this.exportNarratedHTML(htmlFileName)); } } @@ -403,6 +425,20 @@ export class Storage { }); } + /** Generate the content of the narrated HTML file. + * + * The result is derived from the `narrated.html` template. + * + * @param {string} htmlFileName - The name of the presentation HTML file to play. + * @returns {string} - An HTML document content, as text. + */ + exportNarratedHTML(htmlFileName) { + return nunjucks.render("narrated.html", { + pres: this.presentation, + soziHtml: htmlFileName + }); + } + /** Get the path of a file relative to the location of the current SVG file. * * @param {string} filePath - The path of a file. diff --git a/src/js/model/Presentation.js b/src/js/model/Presentation.js index d92dab42..052a2af1 100644 --- a/src/js/model/Presentation.js +++ b/src/js/model/Presentation.js @@ -756,6 +756,25 @@ export class Presentation extends EventEmitter { */ this.updateURLOnFrameChange = true; + /** The narrative file type or "none" if narration is disabled. + * + * @default + * @type {string} */ + this.narrativeType = "none"; + + /** The narrative file name or relative path. + * + * @default + * @type {string} */ + this.narrativeFile = "narrative.flac"; + + /** The narrative time-to-slide data mapping the time + * moments (in seconds) to their corresponding frame number (one-based). + * + * @default + * @type {string} */ + this.narrativeTimeToSlide = "0"; + /** The last export document type. * * @default @@ -922,6 +941,9 @@ export class Presentation extends EventEmitter { enableMouseRotation : this.enableMouseRotation, enableMouseNavigation : this.enableMouseNavigation, updateURLOnFrameChange : this.updateURLOnFrameChange, + narrativeType : this.narrativeType, + narrativeFile : this.narrativeFile, + narrativeTimeToSlide : this.narrativeTimeToSlide, exportType : this.exportType, exportToPDFPageSize : this.exportToPDFPageSize, exportToPDFPageOrientation: this.exportToPDFPageOrientation, @@ -978,6 +1000,9 @@ export class Presentation extends EventEmitter { copyIfSet(this, storable, "enableMouseRotation"); copyIfSet(this, storable, "enableMouseNavigation"); copyIfSet(this, storable, "updateURLOnFrameChange"); + copyIfSet(this, storable, "narrativeType"); + copyIfSet(this, storable, "narrativeFile"); + copyIfSet(this, storable, "narrativeTimeToSlide"); copyIfSet(this, storable, "exportType"); copyIfSet(this, storable, "exportToPDFPageSize"); copyIfSet(this, storable, "exportToPDFPageOrientation"); diff --git a/src/js/narrated.js b/src/js/narrated.js new file mode 100644 index 00000000..702d1ec8 --- /dev/null +++ b/src/js/narrated.js @@ -0,0 +1,94 @@ + +window.addEventListener("load", () => { + const narrative = document.getElementById("narrative") + + const timeToSlide = (() => { + const timeToSlide = new Map() + let prevSlide = 0 + for (const x of narrative.dataset.timeToSlide.split(",")) { + const [time, slide] = x.split(":") + prevSlide = parseInt((slide === undefined) ? prevSlide+1 : slide) + timeToSlide.set(parseInt(time), prevSlide - 1) + } + return timeToSlide + })() + const slideToTimeIntervals = (() => { + const slideToTimeIntervals = new Map() + const process = (start, end, slide) => { + let timeIntervals = slideToTimeIntervals.get(slide) + if (timeIntervals === undefined) { + timeIntervals = [] + slideToTimeIntervals.set(slide, timeIntervals) + } + timeIntervals.push([start, end]) + } + let start, slide + for (const [end, nextSlide] of timeToSlide) { + if (start === undefined) { + start = end + slide = nextSlide + continue + } + process(start, end, slide) + start = end + slide = nextSlide + } + if (start !== undefined) { + process(start, Number.POSITIVE_INFINITY, slide) + } + return slideToTimeIntervals + })() + + let currentSlide = 0 + + const updateAudio = (newSlide) => { + currentSlide = newSlide + const timeIntervals = slideToTimeIntervals.get(newSlide) + if (timeIntervals === undefined) { + narrative.pause() + return + } + const t = narrative.currentTime + let nearestTime + for (const [start, end] of timeIntervals) { + if (start <= t && t < end) { + return + } + if (nearestTime === undefined || start <= t) { + nearestTime = start + } + } + narrative.currentTime = nearestTime + } + const updateSlide = (target) => { + const t = narrative.currentTime + let targetSlide = 0 + for (const [time, slide] of timeToSlide) { + if (time <= t) { + targetSlide = slide + } + } + if (targetSlide !== currentSlide) { + currentSlide = targetSlide + target.postMessage({name:"moveToFrame", args: [currentSlide]},"*") + } + } + window.addEventListener("message", (e) => { + switch(e.data.name) { + case "loaded": + const target = e.source + target.postMessage({name:"notifyOnFrameChange"},"*") + const frame = document.querySelector("iframe") + setInterval(() => { + frame.focus() + }, 1000) + narrative.ontimeupdate = () => { + updateSlide(target) + } + break + case "frameChange": + updateAudio(e.data.index) + break + } + }) +}, false) diff --git a/src/js/view/Properties.js b/src/js/view/Properties.js index 51737738..94f9108c 100644 --- a/src/js/view/Properties.js +++ b/src/js/view/Properties.js @@ -77,6 +77,7 @@ export class Properties extends VirtualDOMView { render() { switch (this.mode) { case "preferences": return this.renderPreferences(); + case "narration": return this.renderNarrationProperties(); case "export": return this.renderExportTool(); default: return this.renderPresentationProperties(); } @@ -368,6 +369,86 @@ export class Properties extends VirtualDOMView { ]); } + /** Render the properties view for a presentation-wide narrative. + * + * Three properties will be configured: + * 1. narrativeType: the audio tag type; this field is a hint to + * guide user in selection of an appropriate format, although + * the browser can automatically detect the correct type by + * itself. If this value is set to "none", narration will be + * disabled. If it is set to flac or ogg, the corresponding + * mimetype will be set for the