source: main/trunk/greenstone3/web/interfaces/default/js/utility_scripts.js.for-gs312@ 37791

Last change on this file since 37791 was 37791, checked in by davidb, 12 months ago

these files contain newer work related to audio track editing however as we are close to releasing gs3.11 we are keeping these as separate files for now

File size: 153.7 KB
Line 
1/** JavaScript file of utility functions.
2 * At present contains functions for sanitising of URLs,
3 * since tomcat 8+, being more compliant with URL/URI standards, is more strict about URLs.
4 */
5
6/*
7 Given a string consisting of a single character, returns the %hex (%XX)
8 https://www.w3resource.com/javascript-exercises/javascript-string-exercise-27.php
9 https://stackoverflow.com/questions/40100096/what-is-equivalent-php-chr-and-ord-functions-in-javascript
10 https://www.w3resource.com/javascript-exercises/javascript-string-exercise-27.php
11*/
12function urlEncodeChar(single_char_string) {
13 /*let hex = Number(single_char_string.charCodeAt(0)).toString(16);
14 var str = "" + hex;
15 str = "%" + str.toUpperCase();
16 return str;
17 */
18
19 var hex = "%" + Number(single_char_string.charCodeAt(0)).toString(16).toUpperCase();
20 return hex;
21}
22
23/*
24 Tomcat 8 appears to be stricter in requiring unsafe and reserved chars
25 in URLs to be escaped with URL encoding
26 See section "Character Encoding Chart of
27 https://perishablepress.com/stop-using-unsafe-characters-in-urls/
28 Reserved chars:
29 ; / ? : @ = &
30 -----> %3B %2F %3F %3A %40 %3D %26
31 [Now also reserved, but no special meaning yet in URLs (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent)
32 and not required to be enforced yet, so we're aren't at present dealing with these:
33 ! ' ( ) *
34 ]
35 Unsafe chars:
36 " < > # % { } | \ ^ ~ [ ] ` and SPACE/BLANK
37 ----> %22 %3C %3E %23 %25 %7B %7D %7C %5C %5E ~ %5B %5D %60 and %20
38 But the above conflicts with the reserved vs unreserved listings at
39 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI
40 Possibly more info: https://stackoverflow.com/questions/1547899/which-characters-make-a-url-invalid
41
42 And the bottom of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
43 lists additional characters that have been reserved since and which need encoding when in a URL component.
44
45 Javascript already provides functions encodeURI() and encodeURIComponent(), see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI
46 However, the set of chars they deal with only partially overlap with the set of chars that need encoding as per the RFC3986 for URIs and RFC1738 for URLs discussed at
47 https://perishablepress.com/stop-using-unsafe-characters-in-urls/
48 We want to handle all the characters listed as unsafe and reserved at https://perishablepress.com/stop-using-unsafe-characters-in-urls/
49 so we define and use our own conceptually equivalent methods for both existing JavaScript methods:
50 - makeSafeURL() for Javascript's encodeURI() to make sure all unsafe characters in URLs are escaped by being URL encoded
51 - and makeSafeURLComponent() for JavaScript's encodeURIComponent to additionally make sure all reserved characters in a URL portion are escaped by being URL encoded too
52
53 Function makeSafeURL() is passed a string that represents a URL and therefore only deals with characters that are unsafe in a URL and which therefore require escaping.
54 Function makeSafeURLComponent() deals with portions of a URL that when decoded need not represent a URL at all, for example data like inline templates passed in as a
55 URL query string's parameter values. As such makeSafeURLComponent() should escape both unsafe URL characters and characters that are reserved in URLs since reserved
56 characters in the query string part (as query param values representing data) may take on a different meaning from their reserved meaning in a URL context.
57*/
58
59/* URL encodes both
60 - UNSAFE characters to make URL safe, by calling makeSafeURL()
61 - and RESERVED characters (characters that have reserved meanings within a URL) to make URL valid, since the url component parameter could use reserved characters
62 in a non-URL sense. For example, the inline template (ilt) parameter value of a URL could use '=' and '&' signs where these would have XSLT rather than URL meanings.
63
64 See end of https://www.w3schools.com/jsref/jsref_replace.asp to use a callback passing each captured element of a regex in str.replace()
65*/
66function makeURLComponentSafe(url_part, encode_percentages) {
67 // https://stackoverflow.com/questions/12797118/how-can-i-declare-optional-function-parameters-in-javascript
68 encode_percentages = encode_percentages || 1; // this method forces the URL-encoding of any % in url_part, e.g. do this for inline-templates that haven't ever been encoded
69
70 var url_encoded = makeURLSafe(url_part, encode_percentages);
71 //return url_encoded.replace(/;/g, "%3B").replace(/\//g, "%2F").replace(/\?/g, "%3F").replace(/\:/g, "%3A").replace(/\@/g, "%40").replace(/=/g, "%3D").replace(/\&/g,"%26");
72 url_encoded = url_encoded.replace(/[\;\/\?\:\@\=\&]/g, function(s) {
73 return urlEncodeChar(s);
74 });
75 return url_encoded;
76}
77
78/*
79 URL encode UNSAFE characters to make URL passed in safe.
80 Set encode_percentages to 1 (true) if you don't want % signs encoded: you'd do so if the url is already partly URL encoded.
81*/
82function makeURLSafe(url, encode_percentages) {
83 encode_percentages = encode_percentages || 0; // https://stackoverflow.com/questions/12797118/how-can-i-declare-optional-function-parameters-in-javascript
84
85 var url_encoded = url;
86 if(encode_percentages) { url_encoded = url_encoded.replace(/\%/g,"%25"); } // encode % first
87 //url_encoded = url_encoded.replace(/ /g, "%20").replace(/\"/g,"%22").replace(/\</g,"%3C").replace(/\>/g,"%3E").replace(/\#/g,"%23").replace(/\{/g,"%7B").replace(/\}/g,"%7D");
88 //url_encoded = url_encoded.replace(/\|/g,"%7C").replace(/\\/g,"%5C").replace(/\^/g,"%5E").replace(/\[/g,"%5B").replace(/\]/g,"%5D").replace(/\`/g,"%60");
89 // Should we handle ~, but then what is its URL encoded value? Because https://meyerweb.com/eric/tools/dencoder/ URLencodes ~ to ~.
90 //return url_encoded;
91 url_encoded = url_encoded.replace(/[\ \"\<\>\#\{\}\|\\^\~\[\]\`]/g, function(s) {
92 return urlEncodeChar(s);
93 });
94 return url_encoded;
95}
96
97/***************
98* MENU SCRIPTS *
99***************/
100function moveScroller() {
101 var move = function() {
102 var editbar = $("#editBar");
103 var st = $(window).scrollTop();
104 var fa = $("#float-anchor").offset().top;
105 if(st > fa) {
106
107 editbar.css({
108 position: "fixed",
109 top: "0px",
110 width: editbar.data("width"),
111 //width: "30%"
112 });
113 } else {
114 editbar.data("width", editbar.css("width"));
115 editbar.css({
116 position: "relative",
117 top: "",
118 width: ""
119 });
120 }
121 };
122 $(window).scroll(move);
123 move();
124}
125
126
127function floatMenu(enabled)
128{
129 var menu = $(".tableOfContentsContainer");
130 if(enabled)
131 {
132 menu.data("position", menu.css("position"));
133 menu.data("width", menu.css("width"));
134 menu.data("right", menu.css("right"));
135 menu.data("top", menu.css("top"));
136 menu.data("max-height", menu.css("max-height"));
137 menu.data("overflow", menu.css("overflow"));
138 menu.data("z-index", menu.css("z-index"));
139
140 menu.css("position", "fixed");
141 menu.css("width", "300px");
142 menu.css("right", "0px");
143 menu.css("top", "100px");
144 menu.css("max-height", "600px");
145 menu.css("overflow", "auto");
146 menu.css("z-index", "200");
147
148 $("#unfloatTOCButton").show();
149 }
150 else
151 {
152 menu.css("position", menu.data("position"));
153 menu.css("width", menu.data("width"));
154 menu.css("right", menu.data("right"));
155 menu.css("top", menu.data("top"));
156 menu.css("max-height", menu.data("max-height"));
157 menu.css("overflow", menu.data("overflow"));
158 menu.css("z-index", menu.data("z-index"));
159
160 $("#unfloatTOCButton").hide();
161 $("#floatTOCToggle").prop("checked", false);
162 }
163
164 var url = gs.xsltParams.library_name + "?a=d&ftoc=" + (enabled ? "1" : "0") + "&c=" + gs.cgiParams.c;
165
166 $.ajax(url);
167}
168
169// TK Label Scripts
170
171var tkMetadataSetStatus = "needs-to-be-loaded";
172var tkMetadataElements = null;
173
174
175function addTKLabelToImage(labelName, definition, name, comment) {
176 // lists of tkLabels and their corresponding codes, in order
177 let tkLabels = ["Attribution","Clan","Family","MultipleCommunities","CommunityVoice","Creative","Verified","NonVerified","Seasonal","WomenGeneral","MenGeneral",
178 "MenRestricted","WomenRestricted","CulturallySensitive","SecretSacred","OpenToCommercialization","NonCommercial","CommunityUseOnly","Outreach","OpenToCollaboration"];
179 let tkCodes = ["tk_a","tk_cl","tk_f","tk_mc","tk_cv","tk_cr","tk_v","tk_nv","tk_s","tk_wg","tk_mg","tk_mr","tk_wr","tk_cs","tk_ss","tk_oc","tk_nc","tk_co","tk_o","tk_cb"];
180 for (let i = 0; i < tkLabels.length; i++) {
181 if (labelName == tkLabels[i]) {
182 let labeldiv = document.querySelectorAll(".tklabels img");
183 for (image of labeldiv) {
184 let labelCode = image.src.substr(image.src.lastIndexOf("/") + 1).replace(".png", ""); // get tk label code from image file name
185 if (labelCode == tkCodes[i]) {
186 image.title = "TK " + name + ": " + definition + " Click for more details."; // set tooltip
187 if (image.parentElement.parentElement.parentElement.classList[0] != "tocSectionTitle") { // disable onclick event in favourites section
188 image.addEventListener("click", function(e) {
189 let currPopup = document.getElementsByClassName("tkPopup")[0];
190 if (currPopup == undefined || (currPopup != undefined && currPopup.id != labelCode)) {
191 let popup = document.createElement("div");
192 popup.classList.add("tkPopup");
193 popup.id = labelCode;
194 let popupText = document.createElement("span");
195 let heading = "<h1>Traditional Knowledge Label:<br><h2>" + name + "</h2></h1>";
196 let moreInformation = "<br> For more information about TK Labels, ";
197 let link = document.createElement("a");
198 link.innerHTML = "click here.";
199 link.href = "https://localcontexts.org/labels/traditional-knowledge-labels/";
200 link.target = "_blank";
201 popupText.innerHTML = heading + comment + moreInformation;
202 popupText.appendChild(link);
203 let closeButton = document.createElement("span");
204 closeButton.innerHTML = "&#215;";
205 closeButton.id = "tkCloseButton";
206 closeButton.title = "Click to close window."
207 closeButton.addEventListener("click", function(e) {
208 closeButton.parentElement.remove();
209 });
210 popup.appendChild(closeButton);
211 popup.appendChild(popupText);
212 e.target.parentElement.appendChild(popup);
213 }
214 if (currPopup) currPopup.remove(); // remove existing popup div
215 });
216 }
217 }
218 }
219 }
220 }
221}
222
223function addTKLabelsToImages(lang) {
224 if (tkMetadataElements == null) {
225 console.error("ajax call not yet loaded tk label metadata set");
226 } else {
227 for (label of tkMetadataElements) { // for each tklabel element in tk.mds
228 let tkLabelName = label.attributes.name.value; // Element name=""
229 let attributes = label.querySelectorAll("[code=" + lang + "] Attribute"); // gets attributes for selected language
230 let tkName = attributes[0].textContent; // name="label"
231 let tkDefinition = attributes[1].textContent; // name="definition"
232 let tkComment = attributes[2].textContent; // name="comment"
233 addTKLabelToImage(tkLabelName, tkDefinition, tkName, tkComment);
234 }
235 }
236}
237
238function loadTKMetadataSetOld(lang) {
239 tkMetadataSetStatus = "loading";
240 $.ajax({
241 url: gs.variables["tkMetadataURL"],
242 success: function(xml) {
243 tkMetadataSetStatus = "loaded";
244 let parser = new DOMParser();
245 let tkmds = parser.parseFromString(xml, "text/xml");
246 tkMetadataElements = tkmds.querySelectorAll("Element");
247 if (document.readyState === "complete") {
248 addTKLabelsToImages(lang);
249 } else {
250 window.onload = function() {
251 addTKLabelsToImages(lang);
252 }
253 }
254 },
255 error: function() {
256 tkMetadataSetStatus = "no-metadata-set-for-this-collection";
257 console.log("No TK Label Metadata-set found for this collection");
258 }
259 });
260};
261function loadTKMetadataSet(lang, type) {
262 if (gs.variables["tkMetadataURL_"+type] == undefined) {
263 console.error("tkMetadataURL_"+type+" variable is not defined, can't load TK Metadata Set");
264 tkMetadataSetStatus = "no-metadata-set-for-this-"+type;
265 return;
266 }
267 tkMetadataSetStatus = "loading";
268 $.ajax({
269 url: gs.variables["tkMetadataURL_"+type],
270 async: false,
271 success: function(xml) {
272 tkMetadataSetStatus = "loaded";
273 let parser = new DOMParser();
274 let tkmds = parser.parseFromString(xml, "text/xml");
275 tkMetadataElements = tkmds.querySelectorAll("Element");
276 if (document.readyState === "complete") {
277 addTKLabelsToImages(lang);
278 } else {
279 window.onload = function() {
280 addTKLabelsToImages(lang);
281 }
282 }
283 },
284 error: function() {
285 tkMetadataSetStatus = "no-metadata-set-for-this-"+type;
286 console.log("No TK Label Metadata-set found for this "+type);
287 }
288 });
289};
290
291// Audio Scripts for Enriched Playback
292
293var wavesurfer;
294
295/**
296 * @param audio input audio file
297 * @param sectionData diarization data (.csv)
298 */
299function loadAudio(audio, sectionData) {
300 const inputFile = sectionData;
301 const mod_meta_base_url = gs.xsltParams.library_name + "?a=g&rt=r&ro=0&s=ModifyMetadata&s1.collection=" + gs.cgiParams.c + "&s1.site=" + gs.xsltParams.site_name + "&s1.d=" + gs.cgiParams.d;
302 const interface_bootstrap_images = "interfaces/" + gs.xsltParams.interface_name + "/images/bootstrap/"; // path to toolbar images
303 const GSSTATUS_SUCCESS = 11; // more information on codes found in: GSStatus.java
304 const audioIdentifier = gs.xsltParams.site_name + ":" + gs.cgiParams.c + ":" + gs.cgiParams.d;
305 const backgroundColour = "rgb(29, 40, 47)";
306 const accentColour = "#66d640";
307 // const accentColour = "#F8C537";
308 const waveformHeight = 140; // height of waveform container
309 const fullscreenWaveformHeight = 200; // height of waveform container in fullscreen mode
310 const regionTransparency = "50"; // transparency of wavesurfer regions
311
312 let editMode = false;
313 let currentRegion = {speaker: '', start: '', end: ''}; // currently selected region
314 let currentRegions = []; // populated with currently selected regions
315
316 let itemType; // type of input item (chapter or word)
317 let longestDuration = 0; // longest region duration, sets max value of filter
318
319 let dualMode = false; // whether user has enabled dual mode
320 let secondaryLoaded = false; // whether user has loaded the secondary set
321 let selectedVersions = ['current'];
322 let previousVersionsExist = true; // if audio has existing versions
323
324 let waveformCursorX = 0;
325 let snappedToX = 0;
326 let snappedTo = "none";
327 let cursorPos = 0;
328 let ctrlDown = false;
329 let mouseDown = false;
330 let newRegionOffset = 0;
331
332 let editsMade = false;
333 let undoLevel = 0;
334 let undoStates = [];
335 let prevUndoState = "";
336 let tempZoomSave = 0;
337 let isZooming;
338
339 let canvasImages = {}; // stores canvas images of each version for fast loading from cache
340
341
342 let colourbrewerSet = colorbrewer.Set2[8];
343 let regionColourSet = [];
344
345
346 let waveformContainer = document.getElementById("waveform");
347 let waveformSpinner = document.getElementById('waveform-blocker');
348 let loader = document.getElementById('waveform-loader');
349 let initialLoad = true;
350
351 wavesurfer = WaveSurfer.create({ // wavesurfer options
352 // autoCenterImmediately: true,
353 container: waveformContainer,
354 backend: "WebAudio",
355 // backgroundColor: "rgb(29, 43, 47)",
356 backgroundColor: backgroundColour,
357 waveColor: "white",
358 progressColor: accentColour,
359 // progressColor: "grey",
360 // barWidth: 2,
361 barMinHeight: 1,
362 // barHeight: 1.2,
363 // barGap: 5,
364 // barRadius: 1,
365 height: waveformHeight,
366 cursorColor: 'black',
367 // maxCanvasWidth: 32000,
368 minPxPerSec: 15, // default 20
369 fillParent: false,
370 partialRender: true, // use the PeakCache to improve rendering speed of large waveforms
371 // pixelRatio: 1, // 1 results in faster rendering
372 scrollParent: true,
373 plugins: [
374 WaveSurfer.regions.create({
375 // formatTimeCallback: function(a, b) {
376 // return "TEST";
377 // }
378 }),
379 WaveSurfer.timeline.create({
380 container: "#wave-timeline",
381 secondaryColor: "white",
382 secondaryFontColor: "white",
383 notchPercentHeight: "0",
384 fontSize: "12",
385 fontFamily: "Courier New"
386 }),
387 WaveSurfer.cursor.create({
388 showTime: true,
389 opacity: 1,
390 customShowTimeStyle: {
391 'background-color': '#000',
392 color: '#fff',
393 padding: '0.25rem',
394 'font-size': '12px'
395 },
396 formatTimeCallback: (num) => { return formatCursor(num); }
397 }),
398 ],
399 });
400
401 // toolbar elements & event handlers
402 const audioContainer = document.getElementById("audioContainer");
403 const dualModeCheckbox = document.getElementById("dual-mode-checkbox");
404 const wave = document.getElementsByTagName("wave")[0];
405 const caretContainer = document.getElementById("caret-container");
406 const primaryCaret = document.getElementById("primary-caret");
407 const secondaryCaret = document.getElementById("secondary-caret");
408 const chapters = document.getElementById("chapters");
409 const chaptersContainer = document.getElementById("chapters-container");
410 const editPanel = document.getElementById("edit-panel");
411 const chapterButton = document.getElementById("chapterButton");
412 const chapterSearchInput = document.getElementById("chapter-search-input");
413 const chapterFilterButton = document.getElementById("funnel-button");
414 const chapterFilterMenu = document.getElementById("filter-menu");
415 const chapterFilterMin = document.getElementById("filter-min");
416 const chapterFilterMax = document.getElementById("filter-max");
417 const zoomOutButton = document.getElementById("zoomOutButton");
418 const zoomSlider = document.getElementById("zoom-slider");
419 const zoomInButton = document.getElementById("zoomInButton");
420 const backButton = document.getElementById("backButton");
421 const playPauseButton = document.getElementById("playPauseButton");
422 const forwardButton = document.getElementById("forwardButton");
423 const editButton = document.getElementById("editorModeButton");
424 const downloadButton = document.getElementById("downloadButton");
425 const muteButton = document.getElementById("muteButton");
426 const volumeSlider = document.getElementById("volume-slider");
427 const fullscreenButton = document.getElementById("fullscreenButton");
428 const changeAllCheckbox = document.getElementById("change-all-checkbox");
429 const changeAllLabel = document.getElementById("change-all-label");
430 const speakerInput = document.getElementById("speaker-input");
431 const startTimeInput = document.getElementById("start-time-input");
432 const endTimeInput = document.getElementById("end-time-input");
433 const removeButton = document.getElementById("remove-button");
434 const createButton = document.getElementById("create-button");
435 const discardButton = document.getElementById("discard-button");
436 const undoButton = document.getElementById("undo-button");
437 const redoButton = document.getElementById("redo-button");
438 const saveButton = document.getElementById("save-button");
439 const hoverSpeaker = document.getElementById("hover-speaker");
440 const contextMenu = document.getElementById("context-menu");
441 const contextReplace = document.getElementById("context-menu-replace");
442 const contextOverdub = document.getElementById("context-menu-overdub");
443 const contextLock = document.getElementById("context-menu-lock");
444 const contextDelete = document.getElementById("context-menu-delete");
445 const contextDownload = document.getElementById("context-menu-download");
446 const timelineMenu = document.getElementById("timeline-menu");
447 const timelineMenuButton = document.getElementById("timeline-menu-button");
448 const timelineMenuHide = document.getElementById("timeline-menu-hide");
449 const timelineMenuDualMode = document.getElementById("timeline-menu-dualmode");
450 const timelineMenuRegionConflict = document.getElementById("timeline-menu-region");
451 const timelineMenuSpeakerConflict = document.getElementById("timeline-menu-speaker");
452 const versionSelectMenu = document.getElementById('version-select-menu');
453 const versionSelectLabels = document.querySelectorAll(".track-arrow");
454 const savePopup = document.getElementById("save-popup");
455 const savePopupBG = document.getElementById("save-popup-bg");
456 const savePopupCancel = document.getElementById("save-popup-cancel");
457 const savePopupCommit = document.getElementById("save-popup-commit");
458 const savePopupCommitMsg = document.getElementById("commit-message");
459
460 audioContainer.addEventListener('fullscreenchange', (e) => fullscreenChanged());
461 audioContainer.addEventListener('contextmenu', onRightClick);
462 audioContainer.addEventListener("keyup", keyUp);
463 audioContainer.addEventListener("keydown", keyDown);
464 dualModeCheckbox.addEventListener("change", () => dualModeChanged());
465 wave.addEventListener('scroll', (e) => waveformScrolled())
466 wave.addEventListener('mousemove', (e) => waveformCursorX = e.x);
467 primaryCaret.addEventListener("click", (e) => caretClicked(e.target.id));
468 secondaryCaret.addEventListener("click", (e) => caretClicked(e.target.id));
469 chapters.style.height = "0px";
470 chaptersContainer.style.height = "0px";
471 editPanel.style.height = "0px";
472 chapterButton.addEventListener("click", () => toggleChapters());
473 chapterSearchInput.addEventListener("input", chapterSearchInputChange);
474 chapterFilterButton.addEventListener("click", chapterFilterButtonClicked);
475 chapterFilterMin.addEventListener("input", durationFilterChanged);
476 chapterFilterMax.addEventListener("input", durationFilterChanged);
477 chapterFilterMin.style["accent-color"] = accentColour;
478 chapterFilterMax.style["accent-color"] = accentColour;
479 zoomOutButton.addEventListener("click", () => { zoomSlider.stepDown(); zoomSlider.dispatchEvent(new Event("input")) });
480 zoomInButton.addEventListener("click", () => { zoomSlider.stepUp(); zoomSlider.dispatchEvent(new Event("input")) });
481 backButton.addEventListener("click", () => { wavesurfer.skipBackward(); });
482 playPauseButton.addEventListener("click", () => { wavesurfer.playPause() });
483 forwardButton.addEventListener("click", () => { wavesurfer.skipForward(); });
484 editButton.addEventListener("click", toggleEditMode);
485 downloadButton.addEventListener("click", () => { downloadURI(audio, gs.documentMetadata.Audio) });
486 muteButton.addEventListener("click", () => {
487 if (volumeSlider.value == 0) wavesurfer.setMute(false)
488 else wavesurfer.setMute(true)
489 });
490 volumeSlider.style["accent-color"] = accentColour;
491 fullscreenButton.addEventListener("click", toggleFullscreen);
492 zoomSlider.style["accent-color"] = accentColour;
493 changeAllCheckbox.addEventListener("change", () => { selectAllCheckboxChanged() });
494 speakerInput.addEventListener("input", speakerChange);
495 speakerInput.addEventListener("blur", speakerInputUnfocused);
496 createButton.addEventListener("click", createNewRegion);
497 removeButton.addEventListener("click", removeRegion);
498 discardButton.addEventListener("click", () => discardRegionChanges(false));
499 undoButton.addEventListener("click", undo);
500 redoButton.addEventListener("click", redo);
501 saveButton.addEventListener("click", saveRegionChanges);
502 document.addEventListener('click', documentClicked);
503 document.addEventListener('mouseup', () => mouseDown = false);
504 document.addEventListener('mousedown', (e) => { if (e.target.id !== "create-button") newRegionOffset = 0 }); // resets new region offset on click
505 document.querySelectorAll('input[type=number]').forEach(e => {
506 e.onchange = (e) => { changeStartEndTime(e) }; // updates speaker objects when number input(s) are changed
507 e.onblur = () => { prevUndoState = "" };
508 });
509 contextReplace.addEventListener("click", replaceSelected);
510 contextOverdub.addEventListener("click", overdubSelected);
511 contextLock.addEventListener("click", toggleLockSelected);
512 contextDelete.addEventListener("click", removeRightClicked);
513 contextDownload.addEventListener("click", downloadRegion);
514 timelineMenu.addEventListener("click", e => e.stopPropagation());
515 timelineMenuButton.addEventListener("click", timelineMenuToggle);
516 timelineMenuHide.addEventListener("click", timelineMenuHideClicked);
517 timelineMenuDualMode.addEventListener("click", () => dualModeChanged());
518 timelineMenuRegionConflict.addEventListener("click", showStartStopConflicts);
519 timelineMenuSpeakerConflict.addEventListener("click", showSpeakerNameConflicts);
520
521 savePopupCancel.addEventListener("click", toggleSavePopup)
522 savePopupCommit.addEventListener("click", commitChanges);
523 savePopupBG.addEventListener("click", toggleSavePopup);
524 versionSelectLabels.forEach(arrow => arrow.addEventListener('click', toggleVersionDropdown));
525
526 volumeSlider.addEventListener("input", function() {
527 wavesurfer.setVolume(this.value);
528 if (this.value == 0) {
529 muteButton.src = interface_bootstrap_images + "mute.svg";
530 muteButton.style.opacity = 0.6;
531 } else {
532 muteButton.src = interface_bootstrap_images + "unmute.svg";
533 muteButton.style.opacity = 1;
534 }
535 });
536
537 zoomSlider.addEventListener('input', function() { // slider changes waveform zoom
538 let sliderValue = Number(this.value) / 4;
539 if (sliderValue < 1) sliderValue = 1;
540 // sliderValue = sliderValue > 1 ? (sliderValue / 4) : 1; // ensure value is greater than 1
541 wavesurfer.zoom(sliderValue);
542 if (currentRegion.speaker && getCurrentRegionIndex() != -1) {
543 setHoverSpeaker(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element.style.left, currentRegion.speaker);
544 drawCurrentRegionBounds();
545 }
546 let handles = document.getElementsByClassName("wavesurfer-handle");
547 if (this.value < 20) {
548 for (const handle of handles) handle.style.setProperty("width", "1px", "important");
549 } else {
550 for (const handle of handles) handle.style.setProperty("width", "3px", "important");
551 }
552 });
553 showAudioLoader();
554
555 if (gs.variables.allowEditing === '0') {
556 editButton.style.display = "none"
557 document.getElementById("track-set-label-top").style.display = "none";
558 document.getElementById("track-set-label-bottom").style.display = "none";
559 timelineMenuDualMode.classList.add('disabled');
560 timelineMenuRegionConflict.classList.add('disabled');
561 timelineMenuSpeakerConflict.classList.add('disabled');
562 }
563
564 wavesurfer.load(audio); // initial audio load
565
566 // wavesurfer events
567
568 wavesurfer.on('region-click', handleRegionClick);
569 wavesurfer.on('region-mouseenter', function(region) { // region hover effects
570 if (!mouseDown) {
571 handleRegionColours(region, true);
572 setHoverSpeaker(region.element.style.left, region.attributes.label.innerText);
573 if (!isInCurrentRegions(region)) {
574 removeRegionBounds();
575 drawRegionBounds(region, wave.scrollLeft, "black");
576 }
577 if (isCurrentRegion(region) && editMode) drawRegionBounds(region, wave.scrollLeft, "FireBrick");
578 }
579 });
580 wavesurfer.on('region-mouseleave', function(region) {
581 hoverSpeaker.innerHTML = "";
582 if (!mouseDown) {
583 if (!(wavesurfer.getCurrentTime() <= region.end && wavesurfer.getCurrentTime() >= region.start)) handleRegionColours(region, false);
584 if (!editMode) hoverSpeaker.innerHTML = "";
585 removeRegionBounds();
586 if (currentRegion && currentRegion.speaker && getCurrentRegionIndex() != -1) {
587 setHoverSpeaker(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element.style.left, currentRegion.speaker);
588 drawCurrentRegionBounds();
589 }
590 }
591 });
592 wavesurfer.on('region-in', function(region) { // play caret enters region
593 if (!mouseDown) {
594 handleRegionColours(region, true);
595 if (itemType == "chapter" && Array.from(chapters.children)[getIndexOfRegion(region)]) {
596 Array.from(chapters.children)[getIndexOfRegion(region)].scrollIntoView({
597 behavior: "smooth",
598 block: "nearest"
599 });
600 }
601 }
602 });
603 wavesurfer.on('region-out', function(region) { handleRegionColours(region, false) });
604 wavesurfer.on('region-update-end', handleRegionEdit); // end of click-drag event
605 wavesurfer.on('region-updated', handleRegionSnap);
606 wavesurfer.on('error', error => console.log(error));
607 wavesurfer.on("play", () => { playPauseButton.src = interface_bootstrap_images + "pause.svg"; });
608 wavesurfer.on("pause", () => { playPauseButton.src = interface_bootstrap_images + "play.svg"; });
609 wavesurfer.on("mute", function(mute) {
610 if (mute) {
611 muteButton.src = interface_bootstrap_images + "mute.svg";
612 muteButton.style.opacity = 0.6;
613 volumeSlider.value = 0;
614 }
615 else {
616 muteButton.src = interface_bootstrap_images + "unmute.svg";
617 muteButton.style.opacity = 1;
618 volumeSlider.value = 1;
619 }
620 });
621
622 wavesurfer.on('ready', function() { // retrieve regions once waveforms have loaded
623 window.onbeforeunload = (e) => {
624 if (undoStates.length > 1) {
625 e.returnValue = "Data will be lost if you leave the page, are you sure?";
626 return "Data will be lost if you leave the page, are you sure?";
627 }
628 };
629 if (document.getElementById('new-canvas')) document.getElementById('new-canvas').remove();
630 setTimeout(() => { // if not delayed exportImage does not retrieve waveform (despite being in waveform-ready?)
631 const currVersion = selectedVersions[(!dualMode || primaryCaret.src.includes("fill")) ? 0 : 1];
632 for (let key in canvasImages) {
633 if (currVersion == key && canvasImages[key] == undefined) { canvasImages[key] = wavesurfer.exportImage() } // add waveform image to cache if one isn't already assigned to the version
634 }
635 }, 1000);
636
637 if (initialLoad) {
638 if (inputFile.endsWith("csv")) { // diarization if csv
639 itemType = "chapter";
640 if (localStorage.getItem(audioIdentifier) !== null) { // localStorage save exists
641 console.log('-- Loading regions from localStorage --');
642 editsMade = true;
643 undoStates = JSON.parse(localStorage.getItem(audioIdentifier)).undoStates;
644 undoLevel = JSON.parse(localStorage.getItem(audioIdentifier)).undoLevel;
645 primarySet.tempSpeakerObjects = undoStates[undoLevel].state;
646 primarySet.speakerObjects = cloneSpeakerObjectArray(primarySet.tempSpeakerObjects);
647 primarySet.uniqueSpeakers = [];
648 for (const item of primarySet.tempSpeakerObjects) {
649 if (!primarySet.uniqueSpeakers.includes(item.speaker)) primarySet.uniqueSpeakers.push(item.speaker);
650 }
651 populateChaptersAndRegions(primarySet);
652 if (undoStates[undoLevel].secState && undoStates[undoLevel].secState.length > 0) {
653 secondarySet.tempSpeakerObjects = undoStates[undoLevel].secState;
654 secondarySet.speakerObjects = cloneSpeakerObjectArray(secondarySet.tempSpeakerObjects);
655 secondarySet.uniqueSpeakers = [];
656 for (const item of secondarySet.tempSpeakerObjects) {
657 if (!secondarySet.uniqueSpeakers.includes(item.speaker)) secondarySet.uniqueSpeakers.push(item.speaker);
658 }
659 secondaryLoaded = true;
660 }
661 updateRegionEditPanel();
662 } else {
663 loadCSVFile(inputFile, primarySet);
664 dualModeChanged(true, "true");
665 setTimeout(()=>{
666 dualModeChanged(true, "false");
667 }, 150);
668 }
669 } else if (inputFile.endsWith("json")) { // transcription if json
670 itemType = "word";
671 loadJSONFile(inputFile);
672 } else {
673 console.log("Filetype of " + inputFile + " not supported.")
674 }
675
676 chapters.style.cursor = "default"; // remove load cursor
677 wave.className = "audio-scroll";
678 $.ajax({
679 type: "GET",
680 url: gs.variables.metadataServerURL,
681 data: { a: 'get-fldv-info', site: gs.xsltParams.site_name, c: gs.cgiParams.c, d: gs.cgiParams.d },
682 dataType: "json",
683 }).then(data => {
684 if (data.includes("ERROR")) {
685 console.log("get-fldv-info Error: " + data);
686 } else if (data.length === 0) {
687 previousVersionsExist = false;
688 // console.log("no previous versions found");
689 $(".track-set-label").hide();
690 // $(".timeline-menu-item").hide();
691 timelineMenuDualMode.remove();
692 timelineMenuRegionConflict.remove();
693 timelineMenuSpeakerConflict.remove();
694 $(".timeline-menu-subtext").remove();
695 } else {
696 for (const version of ["current", ...data]) {
697 canvasImages[version] = undefined;
698 let menuItem = document.createElement("div");
699 menuItem.classList.add("version-select-menu-item");
700 menuItem.id = version;
701 let text = version.includes("nminus") ? version.replace("nminus-", "Previous(") + ")" : version;
702 menuItem.innerText = text.charAt(0).toUpperCase() + text.slice(1);
703 menuItem.addEventListener('click', versionClicked);
704 let dataObj = { a: 'get-archives-metadata', site: gs.xsltParams.site_name, c: gs.cgiParams.c, d: gs.cgiParams.d, metaname: "commitmessage" };
705 if (version != "current") Object.assign(dataObj, {dv: version});
706 $.ajax({ // get commitmessage metadata to show as hover tooltip
707 type: "GET",
708 url: gs.variables.metadataServerURL,
709 data: dataObj,
710 dataType: "text",
711 }).then(comment => {
712 if (data.includes("ERROR")) {
713 console.log("get-archives-metadata Error: " + data);
714 } else {
715 menuItem.title = "Commit message: " + comment;
716 versionSelectMenu.append(menuItem);
717 [...versionSelectMenu.children].sort((a,b) => a.innerText>b.innerText?1:-1).forEach(n=>versionSelectMenu.appendChild(n)); // sort alphabetically
718 }
719 }, (error) => { console.log("get-archives-metadata error:"); console.log(error); });
720 $.ajax({ // get conflict status of each version
721 type: "GET",
722 url: getCSVURLFromVersion(version),
723 dataType: "text",
724 }).then(csvData => {
725 setTimeout(()=>{ // timeout is needed for some reason ?? TODO
726 if (version === "current") checkCSVForConflict("current", "", primarySet.tempSpeakerObjects);
727 else checkCSVForConflict(version, csvData);
728 }, 1000)
729 }, (error) => { console.log("get-archives-metadata error:"); console.log(error); });
730 }
731 }
732 }, (error) => { console.log("get-fldv-info error:"); console.log(error); });
733 initialLoad = false;
734 }
735 // fixes blank waveform/regions when loading Current -> Prev.1 -> Prev.2
736 // **** workaround to avoid getting low-res peaks being drawn
737 wavesurfer.zoom((zoomSlider.value + 4) / 4);
738 wavesurfer.zoom((zoomSlider.value) / 4);
739 hideAudioLoader();
740 });
741
742 /**
743 * Draws conflict icon next to versions containing conflicts
744 * @param {String} version Audio version
745 * @param {String} csvData CSV data of given version
746 * @param {*} spkrObj If CSV data is not given, speakerObjects are instead checked
747 */
748 function checkCSVForConflict(version, csvData, spkrObj) {
749 let hasConflict = false;
750 if (csvData !== "") {
751 let dataLines = csvData.split(/\r\n|\n/);
752 for (const line of dataLines) {
753 const speaker = line.split(",")[0];
754 if (speaker.includes("conflict")) {
755 hasConflict = true;
756 break;
757 }
758 }
759 } else {
760 for (const entry of spkrObj) {
761 if (entry.speaker.includes("conflict")) {
762 hasConflict = true;
763 break;
764 }
765 }
766 }
767 if (hasConflict && document.getElementById(version).children.length === 0) { // draw icon if conflict was found
768 let img = document.createElement("img");
769 img.className = "version-has-conflict";
770 img.src = interface_bootstrap_images + "exclamation-red.svg";
771 document.getElementById(version).append(img);
772 }
773 if (!hasConflict && document.getElementById(version).children.length === 1) { // ensure icon is removed if conflict wasn't found
774 document.getElementById(version).getElementsByClassName("version-has-conflict")[0].remove();
775 }
776 }
777
778 /**
779 * Draws string above waveform at the provided offset
780 * @param {number} offset Offset (from left) to desired location
781 * @param {string} name String to be drawn
782 */
783 function setHoverSpeaker(offset, name) {
784 // replaces 'dur_lock' with clock icon and 'spkr_lock' with person icon to indicate conflict types
785 let icons = document.createElement("span");
786 if (name.includes("dur_lock:")) {
787 let img = document.createElement("img");
788 img.className = "conflict-hover-icon";
789 img.src = interface_bootstrap_images + "clock.svg";
790 img.title = "This region represents a start/stop time conflict";
791 icons.prepend(img);
792 }
793 if (name.includes("spkr_lock:")) {
794 let img = document.createElement("img");
795 img.className = "conflict-hover-icon";
796 img.src = interface_bootstrap_images + "person.svg";
797 img.title = "This region represents a speaker name conflict";
798 icons.prepend(img);
799 }
800 // remove strings from hover string
801 name = name.replace("spkr_lock:", "");
802 name = name.replace("dur_lock:", "");
803 hoverSpeaker.innerHTML = "";
804 hoverSpeaker.prepend(icons);
805 hoverSpeaker.append(name);
806 let newOffset = parseInt(offset.slice(0, -2)) - wave.scrollLeft;
807 hoverSpeaker.style.marginLeft = newOffset + "px";
808 }
809
810 /** Click handler, manages selected region/s, set swapping, region playing */
811 function handleRegionClick(region, e) {
812 if (e.target.classList.contains("region-menu")) return;
813 e.stopPropagation();
814 contextMenu.classList.remove('visible');
815 if (!editMode) { // play region audio on click
816 wavesurfer.play(region.start); // plays from start of region
817 } else { // select or deselect current region
818 if (!region.element) return;
819 chapterSearchInput.value = "";
820 if (region.element.classList.contains("region-top")) {
821 currSpeakerSet = primarySet;
822 swapCarets(true);
823 } else if (region.element.classList.contains("region-bottom")) {
824 currSpeakerSet = secondarySet;
825 swapCarets(false);
826 }
827 prevUndoState = "";
828
829 if (!e.ctrlKey && !e.shiftKey) {
830 currentRegions = [];
831 currentRegion = region;
832 currentRegion.speaker = currentRegion.attributes.label.innerText;
833 wavesurfer.backend.seekTo(currentRegion.start);
834 } else if (e.ctrlKey) { // control was held during click
835 if (currentRegions.length == 0 && isCurrentRegion(region)) {
836 removeCurrentRegion();
837 } else if (getCurrentRegionIndex() != -1 && isInCurrentRegions(region)) {
838 const removeIndex = getIndexInCurrentRegions(region);
839 if (removeIndex != -1) currentRegions.splice(removeIndex, 1);
840 if (currentRegions.length > 0 && isCurrentRegion(region)) { // change current region if removed
841 currentRegion = currentRegions[0];
842 }
843 } else {
844 if (currentRegions.length < 1) currentRegions.push(currentRegion);
845 if (getIndexInCurrentRegions(region) == -1) currentRegions.push(region); // add if it doesn't already exist
846 currentRegion = region;
847 currentRegion.speaker = currentRegion.attributes.label.innerText;
848 wavesurfer.backend.seekTo(currentRegion.start);
849 }
850 if (currentRegions.length == 1) currentRegions = []; // clear selected regions if there is only one
851 } else if (e.shiftKey) { // shift was held during click
852 clearChapterSearch();
853 if (getCurrentRegionIndex() != -1 && getIndexOfRegion(region) != -1) {
854 if (currentRegions && currentRegions.length > 0) {
855 if (Math.max(...getCurrentRegionsIndexes()) < getIndexOfRegion(region)) { // shifting forwards / down
856 currentRegions = currSpeakerSet.tempSpeakerObjects.slice(Math.min(...getCurrentRegionsIndexes()), getIndexOfRegion(region)+1);
857 } else { // shifting backwards / up
858 currentRegions = currSpeakerSet.tempSpeakerObjects.slice(getIndexOfRegion(region), Math.max(...getCurrentRegionsIndexes())+1);
859 }
860 } else {
861 if (getCurrentRegionIndex() < getIndexOfRegion(region)) { // shifting forwards / down
862 currentRegions = currSpeakerSet.tempSpeakerObjects.slice(getCurrentRegionIndex(), getIndexOfRegion(region)+1);
863 } else { // shifting backwards / up
864 currentRegions = currSpeakerSet.tempSpeakerObjects.slice(getIndexOfRegion(region), getCurrentRegionIndex()+1);
865 }
866 }
867 }
868 }
869 if (changeAllCheckbox.checked) { currentRegions = getRegionsWithSpeaker(currentRegion.speaker) }
870 reloadRegionsAndChapters();
871 }
872 }
873
874 /**
875 * Returns index of given region within the currently selected regions
876 * @param {object} region Region within currently selected regions to return index for
877 * @returns {int} Index position of region
878 */
879 function getIndexInCurrentRegions(region) {
880 for (const reg of currentRegions) {
881 const regSpeaker = reg.attributes ? reg.attributes.label.innerText : reg.speaker;
882 if (reg.start == region.start && reg.end == region.end && regSpeaker == region.attributes.label.innerText) {
883 return currentRegions.indexOf(reg);
884 }
885 }
886 return -1;
887 }
888
889 /**
890 * Returns index of region within speakerObject array
891 * @param {object} region Region to return index for
892 * @returns {int} Index position of region
893 */
894 function getIndexOfRegion(region) {
895 for (const reg of currSpeakerSet.tempSpeakerObjects) {
896 if (region.attributes && reg.start == region.start && reg.end == region.end && reg.speaker == region.attributes.label.innerText) {
897 return currSpeakerSet.tempSpeakerObjects.indexOf(reg);
898 }
899 }
900 return -1;
901 }
902
903 /**
904 * Builds metadata-server.pl URL to retrieve audio at given version
905 * @param {string} version GS document version to retrieve from (nminus-X)
906 */
907 function getAudioURLFromVersion(version) {
908 let base_url = gs.variables.metadataServerURL + "?a=get-archives-assocfile&site=" + gs.xsltParams.site_name + "&c=" + gs.cgiParams.c + "&d=" + gs.cgiParams.d;
909 if (version !== "current") base_url += "&dv=" + version // get fldv if not current version
910 return base_url + "&assocname=" + gs.documentMetadata.Audio;
911 }
912
913 /**
914 * Builds metadata-server.pl URL to retrieve CSV at given version
915 * @param {string} version GS document version to retrieve from (nminus-X)
916 */
917 function getCSVURLFromVersion(version) {
918 let base_url = gs.variables.metadataServerURL + "?a=get-archives-assocfile&site=" + gs.xsltParams.site_name + "&c=" + gs.cgiParams.c + "&d=" + gs.cgiParams.d;
919 if (version !== "current") base_url += "&dv=" + version; // get fldv if not current version
920 return base_url + "&assocname=" + "structured-audio.csv";
921 }
922
923 /** Version click handler, first checks if changes have been made and shows popup if true */
924 function versionClicked(e) {
925 let unsavedChanges = false;
926 if (undoStates.length > 0) { // only if changes have been made in track being changed FROM
927 let clickedVersionPos = e.target.parentElement.classList.contains('versionTop') ? 0 : 1;
928 for (const state of undoStates) {
929 if (state.changedTrack == selectedVersions[clickedVersionPos]) {
930 unsavedChanges = true;
931 break;
932 }
933 }
934 }
935 if (unsavedChanges) {
936 const areYouSure = "There are unsaved changes.\nAre you sure you want to lose changes made in this version?";
937 if (window.confirm(areYouSure)) {
938 console.log('OK');
939 discardRegionChanges(true);
940 changeVersion(e);
941 } else {
942 console.log('CANCEL');
943 return;
944 }
945 } else changeVersion(e);
946 }
947
948 /** Changes current audio/csv set to clicked version's equivalent */
949 function changeVersion(e) {
950 removeCurrentRegion();
951 const audio_url = getAudioURLFromVersion(e.target.id);
952 const csv_url = getCSVURLFromVersion(e.target.id);
953 versionSelectMenu.classList.remove('visible');
954 const setToUpdate = e.target.parentElement.classList.contains('versionTop') ? primarySet : secondarySet;
955 if (e.target.parentElement.classList.contains('versionTop')) {
956 if (!currSpeakerSet.isSecondary) {
957 if (dualMode) $(".region-top").remove();
958 else $(".wavesurfer-region").remove();
959 showAudioLoader();
960 // if (canvasImages[e.target.id]) { // if waveform image exists in cache
961 // drawImageOnWaveform(canvasImages[e.target.id]);
962 // }
963 wavesurfer.load(audio_url); // load audio
964 } else {
965 $(".region-top").remove();
966 }
967 document.getElementById('track-set-label-top').children[0].innerText = e.target.id.includes("nminus") ? e.target.id.replace("nminus-", "Previous(") + ")" : "Current"; // update top label text
968 selectedVersions[0] = e.target.id; // update the selected versions
969 } else {
970 if (currSpeakerSet.isSecondary) {
971 if (dualMode) $(".region-bottom").remove();
972 else $(".wavesurfer-region").remove();
973 showAudioLoader();
974 // if (canvasImages[e.target.id]) { // if waveform image exists in cache
975 // drawImageOnWaveform(canvasImages[e.target.id]);
976 // }
977 wavesurfer.load(audio_url);
978 } else {
979 $(".region-bottom").remove();
980 }
981 document.getElementById('track-set-label-bottom').children[0].innerText = e.target.id.includes("nminus") ? e.target.id.replace("nminus-", "Previous(") + ")" : "Current"; // update bottom label text
982 selectedVersions[1] = e.target.id;
983 }
984 loadCSVFile(csv_url, setToUpdate, true);
985 }
986
987 /** Utility function to download audio */
988 function downloadURI(loc, name) {
989 let link = document.createElement("a");
990 link.download = name;
991 link.href = loc;
992 link.click();
993 }
994
995 function downloadRegion(e) {
996 if (e) e.stopPropagation();
997 if (getCurrentRegionIndex() != -1 && currentRegions.length <= 1) { // single selected
998 const region = currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region;
999 const sampleRate = wavesurfer.backend.ac.sampleRate;
1000 const duration = region.end - region.start;
1001 const saveName = (gs.documentMetadata.Title + "-[" + region.attributes.label.innerText + "]").replace(/ /g,"_"); // replaces all spaces with "_" : e.g. Bella_A-[Jim_Wilson]
1002 let link = document.createElement("a");
1003 link.download = saveName;
1004 link.href = bufferToWave(wavesurfer.backend.buffer, Math.round(region.start * sampleRate), Math.round(duration * sampleRate));
1005 link.click();
1006 } else {
1007 console.log("ensure just one region is selected")
1008 }
1009 }
1010
1011 /**
1012 * Convert a audio-buffer segment to a Blob using WAVE representation
1013 * The returned Object URL can be set directly as a source for an Auido element.
1014 * https://stackoverflow.com/questions/60079764/how-to-export-wavesurfer-js-as-audio-file
1015 * @param {int} abuffer Audio buffer
1016 * @param {int} offset Start position in bytes
1017 * @param {int} len Length of download in bytes
1018 */
1019 function bufferToWave(abuffer, offset, len) {
1020
1021 var numOfChan = abuffer.numberOfChannels,
1022 length = len * numOfChan * 2 + 44,
1023 buffer = new ArrayBuffer(length),
1024 view = new DataView(buffer),
1025 channels = [], i, sample,
1026 pos = 0;
1027
1028 // write WAVE header
1029 setUint32(0x46464952); // ChunkID: "RIFF"
1030 setUint32(length - 8); // ChunkSize: file length - 8
1031 setUint32(0x45564157); // Format: "WAVE"
1032
1033 setUint32(0x20746d66); // SubChunk1ID: "fmt "
1034 setUint32(16); // SubChunk1Size: 16
1035 setUint16(1); // AudioFormat: PCM (uncompressed)
1036 setUint16(numOfChan); // NumChannels
1037 setUint32(abuffer.sampleRate); // SampleRate
1038 setUint32(abuffer.sampleRate * 2 * numOfChan); // ByteRate: avg. bytes/sec
1039 setUint16(numOfChan * 2); // BlockAlign: block-align
1040 setUint16(16); // BitsPerSample: 16-bit (hardcoded in this demo)
1041
1042 setUint32(0x61746164); // SubChunk2ID: "data" - chunk
1043 setUint32(length - pos - 4); // SubChunk2Size: chunk length
1044
1045 // write interleaved data
1046 for (i = 0; i < abuffer.numberOfChannels; i++)
1047 channels.push(abuffer.getChannelData(i));
1048
1049 while (pos < length) {
1050 for (i = 0; i < numOfChan; i++) { // interleave channels
1051 sample = Math.max(-1, Math.min(1, channels[i][offset])); // clamp
1052 sample = (0.5 + sample < 0 ? sample * 32768 : sample * 32767)|0; // scale to 16-bit signed int
1053 view.setInt16(pos, sample, true); // update data chunk
1054 pos += 2;
1055 }
1056 offset++ // next source sample
1057 }
1058
1059 // create Blob
1060 return (URL || webkitURL).createObjectURL(new Blob([buffer], {type: "audio/wav"}));
1061
1062 function setUint16(data) { // write two bytes
1063 view.setUint16(pos, data, true);
1064 pos += 2;
1065 }
1066
1067 function setUint32(data) { // write four bytes
1068 view.setUint32(pos, data, true);
1069 pos += 4;
1070 }
1071 }
1072
1073 /** Document click listener for context box closure and region deselection */
1074 function documentClicked(e) { // document on click
1075 if (e.target.classList.contains("region-menu")) return;
1076 contextMenu.classList.remove('visible');
1077 timelineMenu.classList.remove('visible');
1078 versionSelectMenu.classList.remove('visible');
1079 versionSelectLabels.forEach(arrow => {
1080 // arrow.style.transform = 'rotate(90deg)';
1081 // arrow.style.paddingTop = '0';
1082 arrow.style.display = 'inline';
1083 });
1084 // console.log(e.target.classList)
1085 if (editMode && e.target.tagName !== "INPUT" && e.target.tagName !== "IMG" && !e.target.classList.contains("ui-button") && !$("#audio-dropdowns").has($(e.target)).length
1086 && !e.target.classList.contains("context-menu-item") && !e.target.classList.contains("ui-menu-item-wrapper")) {
1087 let currReg = getCurrentRegionIndex() != -1 ? currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region : false; // save for deselection
1088 let currRegs = getCurrentRegionsIndexes().length > 1 ? currentRegions : false; // save for deselection
1089 removeCurrentRegion();
1090 reloadChapterList();
1091 if (currReg != false) regionLeave(currReg); // deselect curr region
1092 if (currRegs != false) {
1093 for (const reg of currRegs) {
1094 regionLeave(reg.region); // deselect curr regions
1095 regionLeave(reg.region); // deselect curr regions
1096 }
1097 }
1098 removeRegionBounds();
1099 removeButton.innerHTML = "Remove Selected Region";
1100 updateRegionEditPanel();
1101 }
1102 }
1103
1104 /** Draws and returns padlock image at given parent element */
1105 function drawPadlock(parent) {
1106 let lockedImg = document.createElement("img");
1107 lockedImg.classList.add("region-padlock");
1108 lockedImg.src = interface_bootstrap_images + "lock.svg";
1109 lockedImg.title = "This region is locked. Click to unlock region.";
1110 parent.prepend(lockedImg);
1111 return lockedImg;
1112 }
1113
1114 /** Draws conflict marker image at given parent element */
1115 function drawConflictMarker(parent) {
1116 let conflictImg = document.createElement("img");
1117 conflictImg.classList.add("region-conflict");
1118 conflictImg.src = interface_bootstrap_images + "exclamation.svg";
1119 conflictImg.title = "This region has conflicts and should be revised.";
1120 parent.prepend(conflictImg);
1121 }
1122
1123 /**
1124 * Draws triple dot menu button and attaches click listener
1125 * @param {object} region Region to attach menu button to
1126 */
1127 function drawRegionMenuButton(region) {
1128 let menuImg = document.createElement("img");
1129 menuImg.src = interface_bootstrap_images + "menu.svg";
1130 menuImg.classList.add("region-menu");
1131 menuImg.title = "Show region options";
1132 menuImg.addEventListener("click", e => {
1133 audioContainer.dispatchEvent(new MouseEvent("contextmenu", { clientX: menuImg.x + 20, clientY: menuImg.y + 5 }));
1134 });
1135 region.element.append(menuImg);
1136 }
1137
1138 /**
1139 * Attaches a click listener to given padlock element
1140 * @param padlock Element to attach listener to
1141 * @param region Associated region
1142 * @param isChapter Whether padlock exists in chapter (true) or wavesurfer region (false)
1143 */
1144 function attachPadlockListener(padlock, region, isChapter) {
1145 if (isChapter == true) {
1146 padlock.addEventListener('click', () => { // attach to chapter padlock
1147 let index = getIndexOfRegion(region);
1148 currSpeakerSet.tempSpeakerObjects[index].locked = false;
1149 padlock.classList.add('hide');
1150 if (currSpeakerSet.tempSpeakerObjects[index].region.element.firstChild) currSpeakerSet.tempSpeakerObjects[index].region.element.firstChild.remove();
1151 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "lockChange", index);
1152 });
1153 } else {
1154 padlock.addEventListener('click', () => { // attach to region padlock
1155 let index = getIndexOfRegion(region);
1156 currSpeakerSet.tempSpeakerObjects[index].locked = false;
1157 padlock.remove();
1158 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "lockChange", index);
1159 });
1160 }
1161 }
1162
1163 /** Locks or unlocks selected region based on its current state */
1164 function toggleLockSelected(e) { // locks / unlocks selected region(s)
1165 if (e) e.stopPropagation();
1166 if (getCurrentRegionIndex() != -1 && currentRegions.length <= 1) { // single selected
1167 let currIndex = getCurrentRegionIndex();
1168 currSpeakerSet.tempSpeakerObjects[currIndex].locked = !e.target.innerText.includes("Unlock");
1169 if (currSpeakerSet.tempSpeakerObjects[currIndex].locked) {
1170 chapters.childNodes[currIndex].childNodes[1].classList.remove('hide');
1171 let lock = drawPadlock(currSpeakerSet.tempSpeakerObjects[currIndex].region.element);
1172 attachPadlockListener(lock, currSpeakerSet.tempSpeakerObjects[currIndex].region, false);
1173 contextLock.innerText = "Unlock Selected";
1174 } else {
1175 chapters.childNodes[currIndex].childNodes[1].classList.add('hide');
1176 if (currSpeakerSet.tempSpeakerObjects[currIndex].region.element.getElementsByClassName("region-padlock").length > 0) {
1177 currSpeakerSet.tempSpeakerObjects[currIndex].region.element.getElementsByClassName("region-padlock")[0].remove();
1178 }
1179 contextLock.innerText = "Lock Selected";
1180 }
1181 } else if (currentRegions.length > 1) { // multiple selected
1182 let toLock = !e.target.innerText.includes("Unlock");
1183 for (const idx of getCurrentRegionsIndexes()) {
1184 currSpeakerSet.tempSpeakerObjects[idx].locked = toLock;
1185 if (currSpeakerSet.tempSpeakerObjects[idx].locked) {
1186 chapters.childNodes[idx].childNodes[1].classList.remove('hide');
1187 if (currSpeakerSet.tempSpeakerObjects[idx].region.element.getElementsByClassName("region-padlock").length == 0) {
1188 let lock = drawPadlock(currSpeakerSet.tempSpeakerObjects[idx].region.element);
1189 attachPadlockListener(lock, currSpeakerSet.tempSpeakerObjects[idx].region, false);
1190 }
1191 contextLock.innerText = "Unlock Selected";
1192 } else {
1193 chapters.childNodes[idx].childNodes[1].classList.add('hide');
1194 if (currSpeakerSet.tempSpeakerObjects[idx].region.element.getElementsByClassName("region-padlock").length > 0) {
1195 currSpeakerSet.tempSpeakerObjects[idx].region.element.getElementsByClassName("region-padlock")[0].remove();
1196 }
1197 contextLock.innerText = "Lock Selected";
1198 }
1199 }
1200 if (document.getElementById("context-menu-lock-2")) document.getElementById("context-menu-lock-2").remove();
1201 }
1202 updateChapterConflictIcons();
1203 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "lockChange", getCurrentRegionIndex());
1204 }
1205
1206 function timelineMenuHideClicked(e) { // hides all regions and chapter/edit divs
1207 if (!e.target.children[0].checked) {
1208 e.target.children[0].checked = true;
1209 timelineMenuDualMode.classList.add('disabled');
1210 timelineMenuRegionConflict.classList.add('disabled');
1211 timelineMenuSpeakerConflict.classList.add('disabled');
1212 if (editPanel.style.height != "0px") toggleEditMode();
1213 if (chapters.style.height != "0px") toggleChapters();
1214 $('.wavesurfer-region').fadeOut(100);
1215 }
1216 else {
1217 e.target.children[0].checked = false;
1218 timelineMenuDualMode.classList.remove('disabled');
1219 timelineMenuRegionConflict.classList.remove('disabled');
1220 timelineMenuSpeakerConflict.classList.remove('disabled');
1221 let fadeIn = true;
1222 if (timelineMenuRegionConflict.firstElementChild.checked) {
1223 showStartStopConflicts(e, true);
1224 fadeIn = false;
1225 }
1226 if (timelineMenuSpeakerConflict.firstElementChild.checked) {
1227 showSpeakerNameConflicts(e, true);
1228 fadeIn = false;
1229 }
1230 if (fadeIn) $('.wavesurfer-region').fadeIn(100);
1231 }
1232 }
1233
1234 function chapterSearchInputChange(e) { // filters chapters and regions by given speaker name
1235 if (e.isTrusted) { // triggered from user action
1236 if (document.getElementById("chapter-alert")) document.getElementById("chapter-alert").remove();
1237 let matches = 0;
1238 reloadChapterList(); // fixes search bug -> space showing up in chapter speaker name
1239 for (const idx in chapters.children) {
1240 if (chapters.children[idx].firstChild && chapters.children[idx].classList.contains("chapter") && currSpeakerSet.tempSpeakerObjects[idx]
1241 && currSpeakerSet.tempSpeakerObjects[idx].region && currSpeakerSet.tempSpeakerObjects[idx].region.element) {
1242 if (e.composed) removeCurrentRegion(); // composed true if called from input, false if manually triggered event
1243 if (!chapters.children[idx].firstChild.innerText.toLowerCase().includes(e.target.value.toLowerCase())) {
1244 chapters.children[idx].style.display = "none";
1245 currSpeakerSet.tempSpeakerObjects[idx].region.element.style.display = "none";
1246 } else if (getDurationFilterMatches().includes(idx)) {
1247 chapters.children[idx].style.display = "flex";
1248 currSpeakerSet.tempSpeakerObjects[idx].region.element.style.display = "";
1249 matches++;
1250 if (e.target.value.length > 0) {
1251 const reg = new RegExp(e.target.value, 'gi'); // [g]lobal, [i]gnore case
1252 chapters.children[idx].firstChild.innerHTML = chapters.children[idx].firstChild.innerText.replace(reg, '<b>$&</b>'); // highlights matching text
1253 } else {
1254 chapters.children[idx].firstChild.innerHTML = chapters.children[idx].firstChild.innerText; // highlights matching text
1255 }
1256 }
1257 }
1258 }
1259 flashChapters();
1260 document.getElementById("filter-count").innerText = "x" + matches;
1261 if (matches == chapters.children.length || matches == 0) document.getElementById("filter-count").innerText = "";
1262 if (matches == 0) {
1263 const msg = document.createElement("span");
1264 msg.innerHTML = "No Matches!";
1265 msg.id = "chapter-alert";
1266 chapters.prepend(msg);
1267 }
1268 updateChapterConflictIcons();
1269 }
1270 }
1271
1272 function clearChapterSearch() { // clears search filter and updates results
1273 chapterSearchInput.value = "";
1274 chapterSearchInput.dispatchEvent(new Event("input"));
1275 }
1276
1277 function chapterFilterButtonClicked(e) {
1278 if (chapterFilterMenu.classList.contains("show")) {
1279 chapterFilterMenu.classList.remove("show");
1280 } else {
1281 chapterFilterMenu.classList.add("show");
1282 }
1283 }
1284
1285 /** Shows or hides regions based on their duration */
1286 function durationFilterChanged(e) {
1287 document.getElementById("filter-min-label").innerText = minutize(chapterFilterMin.value, true) + "s";
1288 document.getElementById("filter-max-label").innerText = minutize(chapterFilterMax.value, true) + "s";
1289 if (document.getElementById("chapter-alert")) document.getElementById("chapter-alert").remove();
1290 let matches = 0;
1291 for (const idx in chapters.children) {
1292 if (chapters.children[idx].firstChild && chapters.children[idx].classList.contains("chapter") && currSpeakerSet.tempSpeakerObjects[idx]
1293 && currSpeakerSet.tempSpeakerObjects[idx].region && currSpeakerSet.tempSpeakerObjects[idx].region.element) {
1294 const duration = currSpeakerSet.tempSpeakerObjects[idx].region.end - currSpeakerSet.tempSpeakerObjects[idx].region.start;
1295 if (duration < chapterFilterMin.value || duration > chapterFilterMax.value) {
1296 chapters.children[idx].style.display = "none";
1297 currSpeakerSet.tempSpeakerObjects[idx].region.element.style.display = "none";
1298 } else if (getSpeakerFilterMatches().includes(idx)){
1299 chapters.children[idx].style.display = "flex";
1300 currSpeakerSet.tempSpeakerObjects[idx].region.element.style.display = "";
1301 matches++;
1302 }
1303 }
1304 }
1305 flashChapters();
1306 document.getElementById("filter-count").innerText = "x" + matches;
1307 if (matches == chapters.children.length || matches == 0) document.getElementById("filter-count").innerText = "";
1308 if (matches == 0) {
1309 const msg = document.createElement("span");
1310 msg.innerHTML = "No Matches!";
1311 msg.id = "chapter-alert";
1312 chapters.prepend(msg);
1313 }
1314 }
1315
1316 /** Utility function for duration filter */
1317 function getSpeakerFilterMatches() {
1318 let out = []
1319 for (const idx in chapters.children) {
1320 if (chapters.children[idx].firstChild && chapters.children[idx].classList.contains("chapter") && currSpeakerSet.tempSpeakerObjects[idx]
1321 && currSpeakerSet.tempSpeakerObjects[idx].region && currSpeakerSet.tempSpeakerObjects[idx].region.element) {
1322 if (chapters.children[idx].firstChild.innerText.toLowerCase().includes(chapterSearchInput.value.toLowerCase())) {
1323 out.push(idx);
1324 }
1325 }
1326 }
1327 return out;
1328 }
1329
1330 /** Utility function for speaker filter */
1331 function getDurationFilterMatches() {
1332 let out = [];
1333 for (const idx in chapters.children) {
1334 if (chapters.children[idx].firstChild && chapters.children[idx].classList.contains("chapter") && currSpeakerSet.tempSpeakerObjects[idx]
1335 && currSpeakerSet.tempSpeakerObjects[idx].region && currSpeakerSet.tempSpeakerObjects[idx].region.element) {
1336 const duration = currSpeakerSet.tempSpeakerObjects[idx].region.end - currSpeakerSet.tempSpeakerObjects[idx].region.start;
1337 if (duration >= chapterFilterMin.value && duration <= chapterFilterMax.value) {
1338 out.push(idx);
1339 }
1340 }
1341 }
1342 return out;
1343 }
1344
1345 /** Hides regions that have identical start/stop time */
1346 function showStartStopConflicts(e, forceRun) {
1347 removeCurrentRegion();
1348 if ((dualMode && !timelineMenuRegionConflict.children[0].checked) || forceRun) {
1349 timelineMenuRegionConflict.children[0].checked = true;
1350 let primHide = [];
1351 let secHide = [];
1352 if (!timelineMenuSpeakerConflict.children[0].checked) hideAll();
1353 for (const primIdx in primarySet.tempSpeakerObjects) {
1354 for (const secIdx in secondarySet.tempSpeakerObjects) {
1355 if (regionsMatch(primarySet.tempSpeakerObjects[primIdx], secondarySet.tempSpeakerObjects[secIdx])) { // if regions have same start/end time, hide
1356 primHide.push(primIdx);
1357 secHide.push(secIdx);
1358 }
1359 }
1360 }
1361 for (const primIdx in primarySet.tempSpeakerObjects) {
1362 if (!primHide.includes(primIdx)) {
1363 primarySet.tempSpeakerObjects[primIdx].region.element.style.display = "";
1364 if (primaryCaret.src.includes('fill')) chapters.children[primIdx].style.display = "flex";
1365 }
1366 }
1367 for (const secIdx in secondarySet.tempSpeakerObjects) {
1368 if (!secHide.includes(secIdx)) {
1369 secondarySet.tempSpeakerObjects[secIdx].region.element.style.display = "";
1370 if (secondaryCaret.src.includes('fill')) chapters.children[secIdx].style.display = "flex";
1371 }
1372 }
1373 } else {
1374 timelineMenuRegionConflict.children[0].checked = false;
1375 if (timelineMenuSpeakerConflict.children[0].checked) showSpeakerNameConflicts(e, true);
1376 else clearConflicts();
1377 }
1378 }
1379
1380 function showSpeakerNameConflicts(e, forceRun) { // shows regions that have identical start/stop time but different names
1381 removeCurrentRegion();
1382 if ((dualMode && !timelineMenuSpeakerConflict.children[0].checked) || forceRun) {
1383 timelineMenuSpeakerConflict.children[0].checked = true;
1384 if (!timelineMenuRegionConflict.children[0].checked) hideAll();
1385 for (const primIdx in primarySet.tempSpeakerObjects) {
1386 for (const secIdx in secondarySet.tempSpeakerObjects) {
1387 if (regionsMatch(primarySet.tempSpeakerObjects[primIdx], secondarySet.tempSpeakerObjects[secIdx]) &&
1388 primarySet.tempSpeakerObjects[primIdx].speaker != secondarySet.tempSpeakerObjects[secIdx].speaker) { // hide if regions match but names don't
1389 primarySet.tempSpeakerObjects[primIdx].region.element.style.display = "";
1390 secondarySet.tempSpeakerObjects[secIdx].region.element.style.display = "";
1391 if (primaryCaret.src.includes('fill')) chapters.children[primIdx].style.display = "flex";
1392 else chapters.children[secIdx].style.display = "flex";
1393 }
1394 }
1395 }
1396 } else {
1397 timelineMenuSpeakerConflict.children[0].checked = false;
1398 if (timelineMenuRegionConflict.children[0].checked) showStartStopConflicts(e, true);
1399 else clearConflicts();
1400 }
1401 }
1402
1403 function clearConflicts() { // shows all regions and chapters
1404 for (const primIdx in primarySet.tempSpeakerObjects) {
1405 for (const secIdx in secondarySet.tempSpeakerObjects) {
1406 primarySet.tempSpeakerObjects[primIdx].region.element.style.display = "";
1407 secondarySet.tempSpeakerObjects[secIdx].region.element.style.display = "";
1408 chapters.children[primIdx].style.display = "flex";
1409 }
1410 }
1411 }
1412
1413 function hideAll() { // hides all regions and chapters
1414 for (const primIdx in primarySet.tempSpeakerObjects) {
1415 for (const secIdx in secondarySet.tempSpeakerObjects) {
1416 primarySet.tempSpeakerObjects[primIdx].region.element.style.display = "none";
1417 secondarySet.tempSpeakerObjects[secIdx].region.element.style.display = "none";
1418 chapters.children[primIdx].style.display = "none";
1419 }
1420 }
1421 }
1422
1423 function timelineMenuToggle(e) { // shows / hides timeline menu
1424 e.stopPropagation();
1425 if (timelineMenu.classList.contains('visible')) {
1426 timelineMenu.classList.remove('visible');
1427 e.target.style.transform = 'rotate(0deg)';
1428 }
1429 else {
1430 timelineMenu.classList.add('visible');
1431 e.target.style.transform = 'rotate(-90deg)';
1432 }
1433 }
1434
1435 function handleRegionSnap(region, e) { // clips region to opposite set region if nearby, called on region update (lots)
1436 if (editMode && currentRegion && !wavesurfer.isPlaying()) {
1437 removeRegionBounds();
1438 setHoverSpeaker(region.element.style.left, currentRegion.speaker);
1439 drawRegionBounds(region, wave.scrollLeft, "FireBrick"); // gets set to red if currRegion
1440 if (e && e.action === "resize" && dualMode && editMode && !ctrlDown) { // won't actuate on drag
1441 let oppositeSet = secondarySet; // look down
1442 if (currSpeakerSet.isSecondary) oppositeSet = primarySet; // look up
1443 if (e.direction === "left") {
1444 region.update({ start: getSnapValue(region.start, oppositeSet.tempSpeakerObjects)});
1445 } else if (e.direction === "right") {
1446 region.update({ end: getSnapValue(region.end, oppositeSet.tempSpeakerObjects)});
1447 }
1448 }
1449 if (e && (e.action === "resize" || e.action === "drag")) {
1450 setInputInSeconds(startTimeInput, region.start);
1451 setInputInSeconds(endTimeInput, region.end);
1452 }
1453 }
1454 }
1455
1456 /**
1457 * Returns snap value if near [snapRadius] adjacent region edge
1458 * @param newDragPos Drag position in seconds to check for
1459 * @param speakerSet Adjacent region set
1460 * @returns {number} If found, returns snapped position, otherwise returns input position
1461 */
1462 function getSnapValue(newDragPos, speakerSet) {
1463 const snapRadius = 1;
1464 for (const region of speakerSet) { // scan opposite region for potential snapping points
1465 if (newDragPos > parseFloat(region.start) - snapRadius && newDragPos < parseFloat(region.start) + snapRadius) {
1466 snappedTo = "start";
1467 if (snappedToX == 0) snappedToX = waveformCursorX;
1468 return region.start;
1469 }
1470 if (newDragPos > parseFloat(region.end) - snapRadius && newDragPos < parseFloat(region.end) + snapRadius) {
1471 snappedTo = "end";
1472 if (snappedToX == 0) snappedToX = waveformCursorX;
1473 return region.end;
1474 }
1475 if (snappedTo !== "none" && (waveformCursorX - snappedToX > 10 || waveformCursorX - snappedToX < -10)) {
1476 snappedTo = "none";
1477 snappedToX = 0;
1478 return cursorPos;
1479 }
1480 }
1481 return newDragPos;
1482 }
1483
1484 function mmssToSeconds(input) {
1485 const arr = input.split(":");
1486 if (arr.length == 2) {
1487 return (parseInt(arr[0]) * 60) + parseInt(arr[1]);
1488 } else if (arr.length == 3) {
1489 return (parseInt(arr[0]) * 3600) + (parseInt(arr[1]) * 60) + parseInt(arr[2]);
1490 } else {
1491 console.error("unexpected input to mmssToSeconds: " + input);
1492 }
1493 }
1494
1495 function removeRightClicked(e) {
1496 if (!e.target.classList.contains('disabled')) {
1497 removeRegion();
1498 }
1499 }
1500
1501 function replaceSelected(e) { // moves selected region across, replaces and removes any overlapping regions in the opposite set
1502 if (!e.target.classList.contains('disabled')) {
1503 let destinationSet = secondarySet; // replace down
1504 if (currSpeakerSet.isSecondary) destinationSet = primarySet; // replace up
1505 let currItems = [currentRegion];
1506 if (currentRegions && currentRegions.length > 0) currItems = currentRegions;
1507 for (let idx = 0; idx < currItems.length; idx++) { // handles both currentRegion and currentRegions
1508 for (let idy = 0; idy < destinationSet.tempSpeakerObjects.length; idy++) {
1509 const reg = destinationSet.tempSpeakerObjects[idy];
1510 if ((parseFloat(reg.start) >= parseFloat(currItems[idx].start) && parseFloat(reg.start) <= parseFloat(currItems[idx].end)) ||
1511 (parseFloat(reg.start) <= parseFloat(currItems[idx].start) && parseFloat(reg.end) >= parseFloat(currItems[idx].start))) {
1512 destinationSet.tempSpeakerObjects.splice(idy, 1); // remove subsequent region
1513 idy--;
1514 }
1515 }
1516 }
1517 copySelected(e, true);
1518 reloadRegionsAndChapters();
1519 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "replace", getCurrentRegionIndex());
1520 }
1521 }
1522
1523 function containsRegion(set, region) { // true if given region exists in given set
1524 for (const item of set) {
1525 if (regionsMatch(region, item)) return true;
1526 }
1527 return false;
1528 }
1529
1530 function overdubSelected(e) { // moves selected region across, merges any overlapping regions in the opposite set
1531 if (!e.target.classList.contains('disabled')) {
1532 let destinationSet = secondarySet; // replace down
1533 if (currSpeakerSet.isSecondary) destinationSet = primarySet; // replace up
1534 let backup;
1535 if (destinationSet.isSecondary) backup = cloneSpeakerObjectArray(primarySet.tempSpeakerObjects); // saves selected set as this process changes values in selected set
1536 else backup = cloneSpeakerObjectArray(secondarySet.tempSpeakerObjects);
1537 copySelected(e, true);
1538 if (!currentRegions || currentRegions.length < 1) { // overdub single
1539 handleSameSpeakerOverlap(getCurrentRegionIndex(), destinationSet, true);
1540 } else { // overdub multiple
1541 for (const item of getCurrentRegionsIndexes().reverse()) { // reverse indexes so index doesn't break when regions are removed
1542 handleSameSpeakerOverlap(item, destinationSet, true);
1543 }
1544 }
1545 if (destinationSet.isSecondary) primarySet.tempSpeakerObjects = backup;
1546 else secondarySet.tempSpeakerObjects = backup;
1547 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "overdub", getCurrentRegionIndex());
1548 reloadRegionsAndChapters();
1549 }
1550 }
1551
1552 function copySelected(e, skipUndoState) { // copies region to opposite set [utility function for replace and overdub]
1553 if (!e.target.classList.contains('disabled')) {
1554 let destinationSet = secondarySet; // copy down
1555 if (currSpeakerSet.isSecondary) destinationSet = primarySet // copy up
1556 const selectedRegion = currentRegion;
1557 if (currentRegions && currentRegions.length > 1) { // copy multiple
1558 destinationSet.tempSpeakerObjects.push(...selectedRegions); // append current regions to dest. set
1559 // currSpeakerSet.isSecondary ? caretClicked("primary-caret") : caretClicked("secondary-caret"); // swap selected speakerSet (clears current regions)
1560 // for (const reg of destinationSet.tempSpeakerObjects) { // restore currentRegions in dest. set
1561 // for (const selReg of selectedRegions) {
1562 // if (regionsMatch(reg, selReg) && !containsRegion(currentRegions, reg)) {
1563 // currentRegions.push(reg);
1564 // }
1565 // }
1566 // if (regionsMatch(reg, selectedRegion)) { currentRegion = reg; }
1567 // }
1568 } else { // copy singular
1569 destinationSet.tempSpeakerObjects.push(selectedRegion); // append current region to dest. set
1570 // currSpeakerSet.isSecondary ? caretClicked("primary-caret") : caretClicked("secondary-caret"); // swap selected speakerSet (clears current regions)
1571 // for (const reg of destinationSet.tempSpeakerObjects) { // restore currentRegion in dest. set
1572 // if (regionsMatch(reg, selectedRegion)) {
1573 // currentRegion = reg;
1574 // break;
1575 // }
1576 // }
1577 }
1578 reloadRegionsAndChapters();
1579 if (!skipUndoState) addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "copy", getCurrentRegionIndex());
1580 }
1581 }
1582
1583 /**
1584 * Shows context menu with various region options
1585 * @param {MouseEvent} e Either right click event or left click triple menu click event
1586 */
1587 function onRightClick(e) {
1588 if ((e.target.classList.contains("wavesurfer-region") || e.target.id === "audioContainer" || e.target.classList.contains("chapter")) && editMode) {
1589 e.preventDefault();
1590 e.stopPropagation();
1591 let clickedRegion; // could be used to select clicked region
1592 for (const reg of currSpeakerSet.tempSpeakerObjects) {
1593 if (reg.region.element.title == e.target.title) {
1594 clickedRegion = reg;
1595 break;
1596 }
1597 }
1598 contextMenu.classList.add("visible");
1599 if (e.clientX + 200 > $(window).width()) contextMenu.style.left = ($(window).width() - 220) + "px"; // ensure menu doesn't clip on right
1600 else contextMenu.style.left = e.clientX + "px";
1601 contextMenu.style.top = e.clientY + "px";
1602
1603 let lockConflict = false;
1604 let selectionContainsConflict = false;
1605 if (currentRegions.length > 1) {
1606 let firstIsLocked = 0;
1607 for (const reg of currentRegions) {
1608 if (firstIsLocked === 0) firstIsLocked = reg.locked;
1609 else if (firstIsLocked != reg.locked) lockConflict = true;
1610 if (reg.speaker.includes("conflict")) selectionContainsConflict = true;
1611 }
1612 }
1613 if (lockConflict) {
1614 contextLock.classList.remove('disabled');
1615 if (!document.getElementById("context-menu-lock-2")) {
1616 let contextLock2 = contextLock.cloneNode();
1617 contextLock.innerText = "Lock Selected";
1618 contextLock2.innerText = "Unlock Selected";
1619 contextLock2.id = "context-menu-lock-2";
1620 contextLock2.addEventListener('click', toggleLockSelected);
1621 contextLock.parentNode.insertBefore(contextLock2, contextLock.nextSibling);
1622 }
1623 } else {
1624 contextLock.classList.remove('disabled');
1625 let currIndex = getCurrentRegionIndex();
1626 if (currSpeakerSet.tempSpeakerObjects[currIndex] && currSpeakerSet.tempSpeakerObjects[currIndex].locked) {
1627 contextLock.innerText = "Unlock Selected";
1628 chapters.childNodes[currIndex].childNodes[1].classList.remove('hide');
1629 } else if (currSpeakerSet.tempSpeakerObjects[currIndex]) {
1630 contextLock.innerText = "Lock Selected";
1631 chapters.childNodes[currIndex].childNodes[1].classList.add('hide');
1632 }
1633 }
1634
1635 if (dualMode && currentRegion && currentRegion.speaker !== "") {
1636 contextReplace.classList.remove('disabled');
1637 contextOverdub.classList.remove('disabled');
1638 } else {
1639 contextLock.classList.add('disabled');
1640 contextDelete.classList.add('disabled');
1641 contextDownload.classList.add('disabled');
1642 contextReplace.classList.add('disabled');
1643 contextOverdub.classList.add('disabled');
1644 }
1645 if (currentRegion && currentRegion.speaker !== "") {
1646 contextLock.classList.remove('disabled');
1647 contextDelete.classList.remove('disabled');
1648 if (currentRegions.length === 0) contextDownload.classList.remove('disabled');
1649 }
1650 if (selectionContainsConflict) { // TODO: needs work
1651 contextLock.classList.add('disabled');
1652 }
1653 if (dualMode) { // manipulate context texts
1654 const actionDirection = currSpeakerSet.isSecondary ? "Up" : "Down";
1655 contextReplace.innerHTML = "Replace Selected " + actionDirection;
1656 contextOverdub.innerHTML = "Overdub Selected " + actionDirection;
1657 }
1658 }
1659 }
1660
1661 function saveSelected(e) {
1662 let csvContent = "data:text/csv;charset=utf-8," + currSpeakerSet.speakerObjects.map(item => "\n" + [item.speaker, item.start, item.end].join());
1663 console.log(csvContent);
1664 var encodedUri = encodeURI(csvContent);
1665 window.open(encodedUri);
1666 }
1667
1668 function keyUp(e) { // key up listener
1669 if (e.key == "Control") ctrlDown = false;
1670 if (e.target.tagName !== "INPUT") {
1671 if (e.code === "Backspace" || e.code === "Delete") removeRegion();
1672 else if (e.code === "Space") { wavesurfer.playPause(); }
1673 else if (e.code === "ArrowLeft") wavesurfer.skipBackward();
1674 else if (e.code === "ArrowRight") wavesurfer.skipForward();
1675 else if (e.code === "KeyL") toggleLockSelected(e);
1676 }
1677 if (e.code == "KeyZ" && e.ctrlKey) undo();
1678 else if (e.code == "KeyY" && e.ctrlKey) redo();
1679 }
1680
1681 function keyDown(e) { // keydown listener
1682 if (e.key == "Control") ctrlDown = true;
1683 if (e.code == "Space" && e.target.tagName.toLowerCase() != "input") e.preventDefault();
1684 }
1685
1686 /**
1687 * Shows / hides secondary speaker set
1688 * @param skipUndoState Utility param - skips the addition of an undo state
1689 * @param overrideValue Utility param - overrides the checkbox state
1690 */
1691 function dualModeChanged(skipUndoState, overrideValue) {
1692 if (overrideValue) dualModeCheckbox.checked = overrideValue == "true" ? true : false;
1693 else dualModeCheckbox.checked = !dualModeCheckbox.checked; // toggle dual mode checkbox
1694 dualMode = dualModeCheckbox.checked;
1695 currSpeakerSet = primarySet;
1696 if (!dualMode) removeCurrentRegion();
1697 clearChapterSearch();
1698 reloadRegionsAndChapters();
1699 if (dualMode && previousVersionsExist) {
1700 if (!secondaryLoaded && !initialLoad) {
1701 const secondaryCSVURL = gs.variables.metadataServerURL + "?a=get-archives-assocfile&site=" + gs.xsltParams.site_name + "&c=" + gs.collectionMetadata.indexStem +
1702 "&d=" + gs.documentMetadata.Identifier + "&assocname=structured-audio.csv&dv=nminus-1";
1703 loadCSVFile(secondaryCSVURL, secondarySet);
1704 secondaryLoaded = true; // ensure secondarySet doesn't get re-read > once
1705 }
1706 document.getElementById("caret-container").style.display = "flex";
1707 timelineMenuRegionConflict.classList.remove("disabled");
1708 timelineMenuSpeakerConflict.classList.remove("disabled");
1709 $('#track-set-label-bottom').fadeIn(100);
1710 selectedVersions[1] = document.getElementById('track-set-label-bottom').children[0].innerText;
1711 } else {
1712 caretClicked('primary-caret');
1713 document.getElementById("caret-container").style.display = "none";
1714 selectedVersions.splice(1, 1); // trim to one version in array
1715 timelineMenuRegionConflict.firstElementChild.checked = false;
1716 timelineMenuSpeakerConflict.firstElementChild.checked = false;
1717 timelineMenuRegionConflict.classList.add("disabled");
1718 timelineMenuSpeakerConflict.classList.add("disabled");
1719 $('#track-set-label-bottom').fadeOut(100);
1720 }
1721 currSpeakerSet = primarySet;
1722 if (!skipUndoState) addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "dualModeChange", getCurrentRegionIndex());
1723 }
1724
1725 /**
1726 * Changes selected speaker set
1727 * @param {string} id ID of clicked caret image
1728 */
1729 function caretClicked(id) {
1730 clearChapterSearch();
1731 if (id === "primary-caret") {
1732 currSpeakerSet = primarySet;
1733 swapCarets(true);
1734 } else if (id === "secondary-caret") {
1735 currSpeakerSet = secondarySet;
1736 swapCarets(false);
1737 }
1738 }
1739
1740 /**
1741 * Loads destination waveform and audio if required, updates caret images
1742 * @param {boolean} toPrimary whether destination set is primary (true) or secondary (false)
1743 */
1744 function swapCarets(toPrimary) {
1745 const currCaretIsPrimary = primaryCaret.src.includes("fill") ? true : false; // initial value before swap
1746 if ((toPrimary && !currCaretIsPrimary) || (!toPrimary && currCaretIsPrimary)) {
1747 removeCurrentRegion(); // ensure currentRegion is only removed if changing speakerSet
1748 flashChapters();
1749 reloadChapterList();
1750 }
1751 if (toPrimary) {
1752 if (!currCaretIsPrimary) {
1753 showAudioLoader();
1754 if (canvasImages[selectedVersions[0]]) { // if waveform image exists in cache
1755 drawImageOnWaveform(canvasImages[selectedVersions[0]]);
1756 // hideAudioLoader();
1757 }
1758 // else showAudioLoader();
1759 let url = gs.variables.metadataServerURL + "?a=get-archives-assocfile&site=" + gs.xsltParams.site_name +
1760 "&c=" + gs.cgiParams.c + "&d=" + gs.cgiParams.d + "&assocname=" + gs.documentMetadata.Audio;
1761 if (selectedVersions[0] !== "current") {
1762 if (selectedVersions[0].includes("Previous")) url += "&dv=" + selectedVersions[0].replace("Previous(", "nminus-").replace(")", "");
1763 else url += "&dv=" + selectedVersions[0];
1764 }
1765 wavesurfer.load(url);
1766 }
1767 primaryCaret.src = interface_bootstrap_images + "caret-right-fill.svg";
1768 secondaryCaret.src = interface_bootstrap_images + "caret-right.svg";
1769 } else {
1770 if (currCaretIsPrimary) {
1771 showAudioLoader();
1772 if (canvasImages[selectedVersions[1]]) {
1773 drawImageOnWaveform(canvasImages[selectedVersions[1]]);
1774 // hideAudioLoader();
1775 }
1776 // else showAudioLoader();
1777 let url = gs.variables.metadataServerURL + "?a=get-archives-assocfile&site=" + gs.xsltParams.site_name +
1778 "&c=" + gs.cgiParams.c + "&d=" + gs.cgiParams.d + "&assocname=" + gs.documentMetadata.Audio;
1779 if (selectedVersions[1] !== "current") {
1780 if (selectedVersions[1].includes("Previous")) url += "&dv=" + selectedVersions[1].replace("Previous(", "nminus-").replace(")", "");
1781 else url += "&dv=" + selectedVersions[1];
1782 }
1783 wavesurfer.load(url);
1784 }
1785 primaryCaret.src = interface_bootstrap_images + "caret-right.svg";
1786 secondaryCaret.src = interface_bootstrap_images + "caret-right-fill.svg";
1787 }
1788 }
1789
1790 /**
1791 * Shows spinning loader over waveform, hides regions
1792 */
1793 function showAudioLoader() {
1794 $('.wavesurfer-region').fadeOut(100);
1795 $(".chapter").fadeOut(100);
1796 $(".track-set-label").fadeOut(100);
1797 waveformSpinner.style.display = 'block';
1798 loader.style.display = "inline";
1799 for (const ele of editPanel.children) ele.classList.add("disabled");
1800 playPauseButton.classList.add("disabled");
1801 }
1802
1803 /**
1804 * Hides spinning loader, brings back regions
1805 */
1806 function hideAudioLoader() {
1807 $('.wavesurfer-region').fadeIn(100);
1808 $(".chapter").fadeIn(100);
1809 if (gs.variables.allowEditing !== "0") {
1810 $("#track-set-label-top").fadeIn(100);
1811 if (dualMode) $('#track-set-label-bottom').fadeIn(100);
1812 }
1813 waveformSpinner.style.display = 'none';
1814 loader.style.display = "none";
1815 for (const ele of editPanel.children) ele.classList.remove("disabled");
1816 updateRegionEditPanel();
1817 playPauseButton.classList.remove("disabled");
1818 }
1819
1820 /**
1821 * Draws given image URL on waveform
1822 * @param image URL of image to be drawn
1823 */
1824 function drawImageOnWaveform(image) {
1825 // console.log('draw waveform image from cache')
1826 if (document.getElementById('new-canvas')) document.getElementById('new-canvas').remove();
1827 var newCanvas = document.createElement("div");
1828 newCanvas.id = "new-canvas";
1829 newCanvas.style.width = wavesurfer.drawer.canvases[0].wave.width + 'px';
1830 newCanvas.style.height = waveformHeight + 'px';
1831 newCanvas.style.backgroundImage = "url('" + image + "')";
1832 waveformContainer.appendChild(newCanvas);
1833 }
1834
1835 /**
1836 * Regenerates chapter list to update any changes made in speakerSet
1837 */
1838 function reloadChapterList() {
1839 chapters.innerHTML = "";
1840 for (let i = 0; i < currSpeakerSet.tempSpeakerObjects.length; i++) {
1841 let chapter = document.createElement("div");
1842 chapter.classList.add("chapter");
1843 chapter.id = "chapter" + i;
1844 let speakerName = document.createElement("span");
1845 speakerName.classList.add("speakerName");
1846 speakerName.innerText = currSpeakerSet.tempSpeakerObjects[i].speaker;
1847 let regionLocked = document.createElement("img");
1848 regionLocked.src = interface_bootstrap_images + "lock.svg";
1849 regionLocked.classList.add("speakerLocked", "hide");
1850 attachPadlockListener(regionLocked, currSpeakerSet.tempSpeakerObjects[i].region, true);
1851 if (currSpeakerSet.tempSpeakerObjects[i].locked && editMode) regionLocked.classList.remove("hide");
1852 let speakerTime = document.createElement("span");
1853 speakerTime.classList.add("speakerTime");
1854 speakerTime.innerHTML = minutize(currSpeakerSet.tempSpeakerObjects[i].start) + " - " + minutize(currSpeakerSet.tempSpeakerObjects[i].end) + "s";
1855 chapter.appendChild(speakerName);
1856 chapter.appendChild(regionLocked);
1857 chapter.appendChild(speakerTime);
1858 chapter.addEventListener("click", chapterClicked);
1859 chapter.addEventListener("mouseenter", e => { chapterEnter(Array.from(e.target.parentElement.children).indexOf(e.target)) });
1860 chapter.addEventListener("mouseleave", e => { chapterLeave(Array.from(e.target.parentElement.children).indexOf(e.target)) });
1861 if (chapterSearchInput.value.length > 0 && !speakerName.innerText.toLowerCase().includes(chapterSearchInput.value.toLowerCase())) {
1862 chapter.style.display = "none";
1863 currSpeakerSet.tempSpeakerObjects[i].region.element.style.display = "none";
1864 }
1865 chapters.appendChild(chapter);
1866 }
1867 }
1868
1869 /**
1870 * Shows / hides chapter section
1871 */
1872 let toggleChapters = function() {
1873 if (chapters.style.height == "0px") {
1874 chapters.style.height = "90%";
1875 chaptersContainer.style.height = "30vh";
1876 chapterSearchInput.placeholder = "Filter by Name...";
1877 } else {
1878 chapters.style.height = "0px";
1879 chaptersContainer.style.height = "0px";
1880 chapterSearchInput.placeholder = "";
1881 }
1882 }
1883
1884 /**
1885 * Object representing elements of a diarization output
1886 * @param {boolean} isSecondary Whether or not the set is secondary/bottom (true) or primary/top (false)
1887 * @param {Array} uniqueSpeakers Array of all unique speaker names within the diarization data, used for colouring regions
1888 * @param {Array} speakerObjects Array of objects containing speaker start/stop times and names
1889 * @param {Array} tempSpeakerObjects Temporary version of speakerObjects, which can be reverted back to if required
1890 */
1891 function SpeakerSet(isSecondary, uniqueSpeakers, speakerObjects, tempSpeakerObjects) {
1892 this.isSecondary = isSecondary;
1893 this.uniqueSpeakers = uniqueSpeakers;
1894 this.speakerObjects = speakerObjects;
1895 this.tempSpeakerObjects = tempSpeakerObjects;
1896 }
1897
1898 let primarySet = new SpeakerSet(false, [], [], [], []);
1899 let secondarySet = new SpeakerSet(true, [], [], [], []);
1900 let currSpeakerSet = primarySet;
1901
1902 /**
1903 * Reads diarization CSV file and populates speakerSet
1904 * @param {string} filename Source destination of input CSV file
1905 * @param {object} speakerSet speaker set to be populated
1906 * @param {boolean} forcePopulate Forces redraw of regions and chapters
1907 */
1908 function loadCSVFile(filename, speakerSet, forcePopulate) { // based on: https://stackoverflow.com/questions/7431268/how-to-read-data-from-csv-file-using-javascript
1909 $.ajax({
1910 type: "GET",
1911 url: filename,
1912 dataType: "text",
1913 }).then(data => {
1914 if (data.includes("ERROR")) {
1915 console.log("loadCSVFile Error: " + data);
1916 } else {
1917 let dataLines = data.split(/\r\n|\n/);
1918 let headers;
1919 let startIndex = 0;
1920 speakerSet.uniqueSpeakers = []; // used for obtaining unique colours
1921 speakerSet.speakerObjects = []; // list of speaker items
1922
1923 if (dataLines[0].split(',').length === 3) headers = ["speaker", "start", "end"]; // assume speaker, start, end
1924 else if (dataLines[0].split(',').length === 4) headers = ["speaker", "start", "end", "locked"]; // assume speaker, start, end, locked
1925 else headers = ["speaker", "start", "end", "locked"]; // this is reached after commit where there are 6 cols: speaker, start, stop, dur_lock, spkr_loc, global_lock
1926
1927 for (let i = startIndex; i < dataLines.length; i++) {
1928 let data = dataLines[i].split(',');
1929 if (data[0] !== "") {
1930 let item = {};
1931 for (let j = 0; j < headers.length; j++) {
1932 item[headers[j]] = data[j];
1933 if (j == 0 && !speakerSet.uniqueSpeakers.includes(data[j])) {
1934 speakerSet.uniqueSpeakers.push(data[j]);
1935 }
1936 }
1937 if (headers.length === 3) item['locked'] = false;
1938 if ((item.end - item.start) > longestDuration) {
1939 longestDuration = item.end - item.start;
1940 }
1941 speakerSet.speakerObjects.push(item);
1942 }
1943 }
1944 longestDuration = Math.ceil(longestDuration);
1945 chapterFilterMax.max = longestDuration;
1946 chapterFilterMin.max = longestDuration;
1947 chapterFilterMax.value = longestDuration;
1948 document.getElementById("filter-max-label").innerText = minutize(longestDuration, true) + "s";
1949 speakerSet.tempSpeakerObjects = cloneSpeakerObjectArray(speakerSet.speakerObjects);
1950 populateChaptersAndRegions(speakerSet); // draw on waveform
1951 // if (!speakerSet.isSecondary || forcePopulate) populateChaptersAndRegions(speakerSet); // prevents secondary set being drawn on first load
1952 resetUndoStates(); // undo stack init
1953 checkCSVForConflict(selectedVersions[speakerSet.isSecondary ? 1 : 0], data);
1954 }
1955 }, (error) => { console.log("loadCSVFile Error:"); console.log(error); });
1956 }
1957
1958 /**
1959 * Populates chapter list div and regions on waveform with given speaker set
1960 * @param {object} data Speaker set object with diarization data
1961 */
1962 function populateChaptersAndRegions(data) {
1963 // colorbrewer is a web tool for guidance in choosing map colour schemes based on a letiety of settings.
1964 // this colour scheme is designed for qualitative data
1965 if (regionColourSet.length < 1) {
1966 for (let i = 0; i < data.uniqueSpeakers.length; i++) { // not tested in cases where there are more than 8 speakers!!
1967 const adjIdx = i%8;
1968 regionColourSet[adjIdx] = { name: data.uniqueSpeakers[i], colour: colourbrewerSet[adjIdx] }
1969 }
1970 }
1971
1972 let isSelectedSet = false;
1973
1974 if ((!data.isSecondary && primaryCaret.src.includes("fill")) || (data.isSecondary && secondaryCaret.src.includes("fill"))) isSelectedSet = true;
1975 data.tempSpeakerObjects = sortSpeakerObjectsByStart(data.tempSpeakerObjects); // sort speakerObjects by start time
1976 if (isSelectedSet || !dualMode) chapters.innerHTML = ""; // clear chapter div for re-population
1977 for (let i = 0; i < data.tempSpeakerObjects.length; i++) {
1978 let chapter = document.createElement("div");
1979 chapter.classList.add("chapter");
1980 chapter.id = "chapter" + i;
1981 let speakerName = document.createElement("span");
1982 speakerName.classList.add("speakerName");
1983 speakerName.innerText = data.tempSpeakerObjects[i].speaker;
1984 let regionLocked = document.createElement("img");
1985 regionLocked.src = interface_bootstrap_images + "lock.svg";
1986 regionLocked.classList.add("speakerLocked", "hide");
1987 attachPadlockListener(regionLocked, data.tempSpeakerObjects[i].region, true);
1988 if (data.tempSpeakerObjects[i].locked && editMode) regionLocked.classList.remove("hide");
1989 let speakerTime = document.createElement("span");
1990 speakerTime.classList.add("speakerTime");
1991 speakerTime.innerHTML = minutize(data.tempSpeakerObjects[i].start) + " - " + minutize(data.tempSpeakerObjects[i].end) + "s";
1992 chapter.appendChild(speakerName);
1993 chapter.appendChild(regionLocked);
1994 chapter.appendChild(speakerTime);
1995 chapter.addEventListener("click", chapterClicked);
1996 chapter.addEventListener("mouseenter", e => { chapterEnter(Array.from(e.target.parentElement.children).indexOf(e.target)) });
1997 chapter.addEventListener("mouseleave", e => { chapterLeave(Array.from(e.target.parentElement.children).indexOf(e.target)) });
1998
1999 let selected = false;
2000 let dummyRegion = { start: data.tempSpeakerObjects[i].start, end: data.tempSpeakerObjects[i].end };
2001
2002 if ((isSelectedSet || !dualMode) && (isCurrentRegion(dummyRegion) || isInCurrentRegions(dummyRegion))) {
2003 chapter.classList.add("selected-chapter");
2004 selected = true;
2005 }
2006
2007 if (isSelectedSet || !dualMode) chapters.appendChild(chapter);
2008
2009 let regColour;
2010 if (regionColourSet.find(item => item.name === data.tempSpeakerObjects[i].speaker)) {
2011 regColour = regionColourSet.find(item => item.name === data.tempSpeakerObjects[i].speaker).colour;
2012 } else {
2013 regionColourSet.push({ name: data.tempSpeakerObjects[i].speaker, colour: colourbrewerSet[(i+1)%8]});
2014 regColour = regionColourSet.at(-1).colour;
2015 }
2016
2017 let associatedReg = wavesurfer.addRegion({ // create associated wavesurfer region
2018 id: "region" + i,
2019 start: data.tempSpeakerObjects[i].start,
2020 end: data.tempSpeakerObjects[i].end,
2021 drag: editMode,
2022 resize: editMode,
2023 attributes: {
2024 label: speakerName,
2025 },
2026 color: regColour + regionTransparency,
2027 ...(selected) && {color: "rgba(255,50,50,0.5)"},
2028 });
2029 data.tempSpeakerObjects[i].region = associatedReg;
2030
2031 if (editMode && data.tempSpeakerObjects[i].speaker.includes("conflict")) {
2032 drawConflictMarker(associatedReg.element); // draw conflict icon on region
2033 }
2034 if (selected) { // show padlock and menu button if region is selected
2035 drawRegionMenuButton(associatedReg);
2036 if (data.tempSpeakerObjects[i].locked) { // add padlock to regions if they are selected and locked
2037 let lock = drawPadlock(associatedReg.element);
2038 attachPadlockListener(lock, associatedReg, false);
2039 }
2040 }
2041 }
2042 if (waveformSpinner.style.display == 'block') $(".wavesurfer-region").fadeOut(100); // keep regions hidden until wavesurfer.load() has finished
2043 let handles = document.getElementsByTagName('handle');
2044 for (const handle of handles) handle.addEventListener('mousedown', () => mouseDown = true);
2045
2046 let regions = document.getElementsByTagName("region");
2047 if (dualMode) {
2048 if (document.getElementsByClassName("region-top").length == 0) {
2049 for (const reg of regions) {
2050 if (reg.classList.length == 1) reg.classList.add("region-top");
2051 }
2052 } else {
2053 for (const rego of regions) {
2054 if (!rego.classList.contains("region-top") && rego.classList.length == 1) rego.classList.add("region-bottom");
2055 }
2056 }
2057 }
2058 if (editMode) for (const reg of regions) reg.style.setProperty("z-index", "3", "important");
2059 else for (const reg of regions) reg.style.setProperty("z-index", "1", "important");
2060
2061 chapterSearchInput.dispatchEvent(new Event("input"));
2062 updateChapterConflictIcons();
2063 }
2064
2065 function loadJSONFile(filename) {
2066 $.ajax({
2067 type: "GET",
2068 url: filename,
2069 dataType: "text",
2070 }).then(function(data){ populateWords(JSON.parse(data)) }, (error) => { console.log("loadJSONFile error:"); console.log(error); });
2071 }
2072
2073 function populateWords(data) { // populates word section and adds regions to waveform
2074 let transcription = data.transcription;
2075 let words = data.words;
2076 let wordContainer = document.createElement("div");
2077 wordContainer.id = "word-container";
2078 for (let i = 0; i < words.length; i++) {
2079 let word = document.createElement("span");
2080 word.id = "word" + i;
2081 word.classList.add("word");
2082 word.innerHTML = transcription.split(" ")[i];
2083 word.addEventListener("click", e => { wordClicked(data, e.target.id) });
2084 word.addEventListener("mouseover", e => { chapterEnter(Array.from(e.target.parentElement.children).indexOf(e.target)) });
2085 word.addEventListener("mouseleave", e => { chapterLeave(Array.from(e.target.parentElement.children).indexOf(e.target)) });
2086 wordContainer.appendChild(word);
2087 wavesurfer.addRegion({
2088 id: "region" + i,
2089 start: words[i].startTime,
2090 end: words[i].endTime,
2091 drag: false,
2092 resize: false,
2093 color: "rgba(255, 255, 255, 0.1)",
2094 });
2095 }
2096 chapters.appendChild(wordContainer);
2097 }
2098
2099 let chapterClicked = function(e) { // plays audio from start of chapter
2100 const index = Array.from(chapters.children).indexOf(e.target);
2101 if (currSpeakerSet.tempSpeakerObjects[index]) {
2102 let clickedRegion = currSpeakerSet.tempSpeakerObjects[index].region;
2103 handleRegionClick(clickedRegion, e);
2104 }
2105 }
2106
2107 function wordClicked(data, id) { // plays audio from start of word
2108 let index = id.replace("word", "");
2109 let start = data.words[index].startTime;
2110 wavesurfer.play(start);
2111 }
2112
2113 function chapterEnter(idx) {
2114 let reg = currSpeakerSet.tempSpeakerObjects[idx].region;
2115 regionEnter(reg);
2116 setHoverSpeaker(reg.element.style.left, reg.attributes.label.innerText);
2117 if (!isInCurrentRegions(reg)) {
2118 removeRegionBounds();
2119 drawRegionBounds(reg, wave.scrollLeft, "black");
2120 }
2121 }
2122
2123 function chapterLeave(idx) {
2124 regionLeave(currSpeakerSet.tempSpeakerObjects[idx].region);
2125 removeRegionBounds();
2126 hoverSpeaker.innerHTML = "";
2127 if (currentRegion.speaker && getCurrentRegionIndex() != -1) {
2128 setHoverSpeaker(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element.style.left, currentRegion.speaker);
2129 drawCurrentRegionBounds();
2130 }
2131 }
2132 /**
2133 * Handles region and chapter colours
2134 * @param {object} region Region element to adjust
2135 * @param {boolean} highlight Whether or not region should be white-highlighted
2136 */
2137 function handleRegionColours(region, highlight) { // handles region, chapter & word colours
2138 if (!dualMode || (region.element.classList.contains("region-top") && primaryCaret.src.includes("fill")) || region.element.classList.contains("region-bottom") && secondaryCaret.src.includes("fill")) {
2139 let colour;
2140 if (highlight) {
2141 colour = "rgb(81, 90, 90)";
2142 regionEnter(region);
2143 } else {
2144 colour = "";
2145 regionLeave(region);
2146 }
2147 if (isCurrentRegion(region) || isInCurrentRegions(region)) {
2148 colour = "rgba(255, 50, 50, 0.5)";
2149 if (editMode && region.element.getElementsByClassName("region-menu").length == 0) {
2150 drawRegionMenuButton(region);
2151 }
2152 }
2153 if (chapters.childNodes[getIndexOfRegion(region)]) chapters.childNodes[getIndexOfRegion(region)].style.backgroundColor = colour;
2154 }
2155 }
2156
2157 function regionEnter(region) {
2158 if (isCurrentRegion(region) || isInCurrentRegions(region)) {
2159 region.update({ color: "rgba(255, 50, 50, 0.5)" });
2160 } else {
2161 region.update({ color: "rgba(255, 255, 255, 0.3)" });
2162 }
2163 const currRegion = currSpeakerSet.tempSpeakerObjects[getIndexOfRegion(region)];
2164 if (editMode && currRegion) {
2165 if (currRegion.locked && region.element.getElementsByClassName("region-padlock").length == 0) { // hovered region is locked
2166 let lock = drawPadlock(region.element);
2167 attachPadlockListener(lock, region, false);
2168 }
2169 if (currRegion.speaker.includes("conflict") && region.element.getElementsByClassName("region-conflict").length == 0) {
2170 drawConflictMarker(region.element);
2171 }
2172 }
2173 }
2174
2175 function regionLeave(region) {
2176 if (itemType == "chapter") {
2177 if (isCurrentRegion(region) || isInCurrentRegions(region)) {
2178 region.update({ color: "rgba(255, 50, 50, 0.5)" });
2179 // } else if (!(wavesurfer.getCurrentTime() + 0.1 < region.end && wavesurfer.getCurrentTime() > region.start)) {
2180 } else {
2181 let index = region.id.replace("region", "");
2182 if (regionColourSet.find(item => item.name === currSpeakerSet.tempSpeakerObjects[index].speaker)) {
2183 region.update({ color: regionColourSet.find(item => item.name === currSpeakerSet.tempSpeakerObjects[index].speaker).colour + regionTransparency });
2184 } else {
2185 regionColourSet.push({ name: currSpeakerSet.tempSpeakerObjects[index].speaker, colour: colourbrewerSet[(index+1)%8]});
2186 region.update({ color: regionColourSet.at(-1).colour + regionTransparency });
2187 }
2188 }
2189 if (region.element.getElementsByTagName("img").length > 0 && !isCurrentRegion(region) && !isInCurrentRegions(region)) {
2190 for (let child of Array.from(region.element.children)) {
2191 if (child.tagName == "IMG" && !child.classList.contains("region-conflict")) {
2192 child.remove();
2193 }
2194 }
2195 }
2196 } else {
2197 region.update({ color: "rgba(255, 255, 255, 0.1)" });
2198 }
2199 }
2200
2201 function minutize(num, trimLeadingZeros) { // converts seconds to m:ss for chapters & waveform hover
2202 let date = new Date(null);
2203 date.setSeconds(num);
2204 date = date.toTimeString().split(" ")[0].substring(3);
2205 if (trimLeadingZeros && date.startsWith("00")) date = date.slice(3);
2206 return date;
2207 }
2208
2209 function formatCursor(num) {
2210 cursorPos = num;
2211 return minutize(num);
2212 }
2213
2214 function getLetter(val) {
2215 let speakerNum = parseInt(val.replace("SPEAKER_",""));
2216 return String.fromCharCode(65 + speakerNum); // 'A' == UTF-16 65
2217 }
2218
2219 function toggleEditMode(skipDualModeToggle) { // toggles edit panel and redraws regions with resize handles
2220 if (gs.variables.allowEditing === '1') {
2221 toggleEditPanel();
2222 updateRegionEditPanel();
2223 reloadChapterList();
2224 }
2225 }
2226
2227 function toggleVersionDropdown(e) {
2228 e.stopPropagation();
2229 if (versionSelectMenu.classList.contains("visible")) {
2230 e.target.style.display = 'inline';
2231 versionSelectMenu.classList.remove("visible");
2232 }
2233 else {
2234 e.target.style.display = 'none';
2235 versionSelectMenu.classList.add("visible");
2236 versionSelectMenu.style.top = "2rem";
2237 versionSelectMenu.style.height = wave.clientHeight + wavesurfer.timeline.container.clientHeight + document.getElementById("audio-toolbar").clientHeight - 6 + "px";
2238 if (e.target.parentElement.id.includes("top")) versionSelectMenu.classList.add("versionTop");
2239 else versionSelectMenu.classList.remove("versionTop");
2240 for (version of versionSelectMenu.children) { // handle disabling of regions if being viewed
2241 if (selectedVersions.includes(version.id) || selectedVersions.includes(version.innerText)) version.classList.add('disabled');
2242 else version.classList.remove('disabled');
2243 }
2244 }
2245 }
2246
2247 function toggleEditPanel() { // show & hide edit panel
2248 removeCurrentRegion();
2249 hoverSpeaker.innerHTML = "";
2250 if (editPanel.style.height == "0px") {
2251 if (chapters.style.height == "0px") toggleChapters(); // expands chapter panel
2252 editPanel.style.height = "30vh";
2253 editPanel.style.padding = "0.5rem";
2254 setRegionEditMode(true);
2255 } else {
2256 editPanel.style.height = "0px";
2257 editPanel.style.padding = "0px";
2258 setRegionEditMode(false);
2259 }
2260 }
2261
2262 function setRegionEditMode(state) {
2263 editMode = state;
2264 chapters.innerHTML = '';
2265 $('.wavesurfer-region').hide();
2266 reloadRegionsAndChapters(); // editMode sets drag/resize property when regions are redrawn
2267 }
2268
2269 /**
2270 * Handles the edit of region start time, stop time, or speaker name, updating the speaker set
2271 * @param {object} region Region that has been updated
2272 */
2273 function handleRegionEdit(region, e) {
2274 if (region.element.classList.contains("region-bottom")) { currSpeakerSet = secondarySet; swapCarets(false) }
2275 else { currSpeakerSet = primarySet; swapCarets(true) }
2276 editsMade = true;
2277 currentRegion = region;
2278 wavesurfer.backend.seekTo(region.start);
2279 let regionIndex = getCurrentRegionIndex();
2280 currentRegion.speaker = currSpeakerSet.tempSpeakerObjects[regionIndex].speaker;
2281 currSpeakerSet.tempSpeakerObjects[regionIndex].region = region;
2282 currSpeakerSet.tempSpeakerObjects[regionIndex].start = region.start;
2283 currSpeakerSet.tempSpeakerObjects[regionIndex].end = region.end;
2284
2285 const chaps = chapters.childNodes; // chapter list
2286 chaps[regionIndex].childNodes[1].textContent = minutize(region.start) + " - " + minutize(region.end) + "s"; // update chapter item time
2287 currSpeakerSet.tempSpeakerObjects[regionIndex].region.update({start: region.start, end: region.end}); // update start/end
2288
2289 handleSameSpeakerOverlap(getCurrentRegionIndex(), currSpeakerSet); // recalculate index in case start pos has changed
2290 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "dragdrop", getCurrentRegionIndex());
2291 editLockedRegion(currSpeakerSet.tempSpeakerObjects[regionIndex], chaps);
2292
2293 editPanel.click(); // fixes buttons needing to be clicked twice (unknown cause!)
2294 }
2295
2296 /**
2297 * Shows popup to ensure user is aware they are editing a locked region
2298 * @param {object} region Region that is being edited
2299 */
2300 function editLockedRegion(region) { // ensures user is aware region being edited is locked
2301 if (region.locked) {
2302 let confirm = false;
2303 confirm = window.confirm("Editing a locked region will unlock it, are you sure you want to continue?");
2304 if (!confirm) undo(); // undo change if no
2305 else { // remove lock if yes
2306 region.locked = false;
2307 if (region.region && region.region.element.firstChild) region.region.element.firstChild.remove(); // remove region padlock
2308 if (chapters.childNodes[getCurrentRegionIndex()] && chapters.childNodes[getCurrentRegionIndex()].childNodes[1].tagName === "IMG") {
2309 chapters.childNodes[getCurrentRegionIndex()].childNodes[1].classList.add('hide'); // remove chapter padlock
2310 }
2311 }
2312 }
2313 }
2314
2315 /**
2316 * Merges same-speaker regions with overlapping bounds
2317 * @param {int} regionIdx Index of dragged/edited region
2318 * @param {object} speakerSet Speaker set dragged region exists in
2319 * @param {boolean} skipCurrentRegionUpdate Whether or not to skip the updating of current region
2320 */
2321 function handleSameSpeakerOverlap(regionIdx, speakerSet, skipCurrentRegionUpdate) {
2322 let draggedRegion = speakerSet.tempSpeakerObjects[regionIdx]; // regionIdx may point to a different region within the for-loop after adjustments, so defined here
2323 let draggedRegionSpeaker = draggedRegion.speaker;
2324 for (let i = 0; i < speakerSet.tempSpeakerObjects.length; i++) {
2325 if (speakerSet.tempSpeakerObjects[i].speaker === draggedRegionSpeaker && !regionsMatch(draggedRegion, speakerSet.tempSpeakerObjects[i])) { // ensure speaker name match
2326 if (parseFloat(speakerSet.tempSpeakerObjects[i].start) <= parseFloat(draggedRegion.end) && parseFloat(draggedRegion.start) <= parseFloat(speakerSet.tempSpeakerObjects[i].end)) { // ensure overlap
2327 draggedRegion.start = Math.min(speakerSet.tempSpeakerObjects[i].start, draggedRegion.start);
2328 draggedRegion.end = Math.max(speakerSet.tempSpeakerObjects[i].end, draggedRegion.end);
2329 draggedRegion.region.update({start: Math.min(speakerSet.tempSpeakerObjects[i].start, draggedRegion.start), end: Math.max(speakerSet.tempSpeakerObjects[i].end, draggedRegion.end)});
2330 if (!skipCurrentRegionUpdate) currentRegion = draggedRegion;
2331 speakerSet.tempSpeakerObjects[i].region.remove();
2332 speakerSet.tempSpeakerObjects.splice(i, 1); // remove consumed region
2333 setInputInSeconds(startTimeInput, draggedRegion.region.start); // update number inputs
2334 setInputInSeconds(endTimeInput, draggedRegion.region.end);
2335 i = -1; // reset for loop to support multiple consumptions
2336 }
2337 }
2338 }
2339 for (let i = 0; i < speakerSet.tempSpeakerObjects.length; i++) { // remove duplicates
2340 if (speakerSet.tempSpeakerObjects[i] && speakerSet.tempSpeakerObjects[i+1]) {
2341 if (regionsMatch(speakerSet.tempSpeakerObjects[i], speakerSet.tempSpeakerObjects[i+1])) {
2342 speakerSet.tempSpeakerObjects[i+1].region.remove();
2343 speakerSet.tempSpeakerObjects.splice(i+1, 1); // remove consumed region
2344 i--;
2345 }
2346 }
2347 }
2348 }
2349
2350 /**
2351 * Updates the edit panel elements based on various editing states
2352 */
2353 function updateRegionEditPanel() {
2354 if (currentRegion && currentRegion.speaker == "") {
2355 removeButton.classList.add("disabled");
2356 speakerInput.classList.add("disabled");
2357 changeAllCheckbox.classList.add("disabled");
2358 changeAllCheckbox.disabled = true;
2359 disableStartEndInputs();
2360 speakerInput.readOnly = true;
2361 speakerInput.value = "";
2362 } else {
2363 removeButton.classList.remove("disabled");
2364 speakerInput.classList.remove("disabled");
2365 changeAllCheckbox.classList.remove("disabled");
2366 if (!isZooming) { changeAllCheckbox.disabled = false; }
2367 enableStartEndInputs();
2368 speakerInput.readOnly = false;
2369 }
2370 if (editsMade) {
2371 discardButton.classList.remove("disabled");
2372 saveButton.classList.remove("disabled");
2373 } else {
2374 discardButton.classList.add("disabled");
2375 saveButton.classList.add("disabled");
2376 }
2377 if (changeAllCheckbox.checked) {
2378 // changeAllLabel.innerHTML = "Change all (x" + currentRegions.length + ")";
2379 disableStartEndInputs();
2380 }
2381 if (currentRegion && currentRegion.speaker != "") {
2382 speakerInput.value = currentRegion.speaker;
2383 setInputInSeconds(startTimeInput, currentRegion.start);
2384 setInputInSeconds(endTimeInput, currentRegion.end);
2385 }
2386 if (undoLevel - 1 < 0) undoButton.classList.add("disabled");
2387 else undoButton.classList.remove("disabled");
2388 if (undoLevel + 1 >= undoStates.length) redoButton.classList.add("disabled");
2389 else redoButton.classList.remove("disabled");
2390 }
2391
2392 /**
2393 * Adds a new region to the waveform at the current caret location with the speaker name "NEW_SPEAKER"
2394 */
2395 function createNewRegion() { // adds a new region to the waveform
2396 clearChapterSearch();
2397 const speaker = "NEW_SPEAKER"; // default name
2398 if (!currSpeakerSet.uniqueSpeakers.includes(speaker)) { currSpeakerSet.uniqueSpeakers.push(speaker) }
2399 const start = newRegionOffset + wavesurfer.getCurrentTime();
2400 const end = newRegionOffset + wavesurfer.getCurrentTime() + 15;
2401 newRegionOffset += 5; // offset new region if multiple new regions are created.
2402 currSpeakerSet.tempSpeakerObjects.push({speaker: speaker, start: start, end: end});
2403
2404 editsMade = true;
2405 currentRegions = [];
2406 currentRegion = getRegionFromProps({speaker: speaker, start: start, end: end});
2407 reloadRegionsAndChapters();
2408 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "create", getCurrentRegionIndex());
2409 }
2410
2411 function getRegionFromProps(props, speakerSet) { // find region using speaker, start & end time
2412 if (!speakerSet) speakerSet = currSpeakerSet;
2413 for (let i = 0; i < speakerSet.tempSpeakerObjects.length; i++) {
2414 if (speakerSet.tempSpeakerObjects[i].speaker === props.speaker && speakerSet.tempSpeakerObjects[i].start === props.start && speakerSet.tempSpeakerObjects[i].end === props.end) {
2415 return speakerSet.tempSpeakerObjects[i];
2416 }
2417 }
2418 console.log("getRegionFromProps failed to find matching region");
2419 }
2420
2421 /**
2422 * Removes the currently selected region or regions
2423 */
2424 function removeRegion() {
2425 if (!removeButton.classList.contains("disabled")) {
2426 if (getCurrentRegionIndex() != -1) { // if currentRegion has been set
2427 let currentRegionIndex = getCurrentRegionIndex();
2428 let currentRegionIndexes = getCurrentRegionsIndexes();
2429 let lockTemplate = { locked: currSpeakerSet.tempSpeakerObjects[currentRegionIndex].locked };
2430 for (let i = 0; i < currSpeakerSet.tempSpeakerObjects.length; i++) {
2431 if (isCurrentRegion(currSpeakerSet.tempSpeakerObjects[i].region)) {
2432 currSpeakerSet.tempSpeakerObjects[i].region.remove();
2433 currSpeakerSet.tempSpeakerObjects.splice(i, 1); // remove from tempSpeakerObjects
2434 editsMade = true;
2435 if (i >= 0) i--; // decrement index for side-by-side regions
2436 if (!changeAllCheckbox.checked && currentRegions.length < 1) {
2437 removeCurrentRegion();
2438 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "remove", currentRegionIndex);
2439 updateRegionEditPanel();
2440 reloadChapterList();
2441 editLockedRegion(lockTemplate);
2442 return; // jump out of function
2443 }
2444 } else if (isInCurrentRegions(currSpeakerSet.tempSpeakerObjects[i])) {
2445 currSpeakerSet.tempSpeakerObjects[i].region.remove();
2446 currSpeakerSet.tempSpeakerObjects.splice(i, 1);
2447 if (i >= 0) i--;
2448 }
2449 }
2450 removeCurrentRegion();
2451 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "remove", currentRegionIndex, currentRegionIndexes); // multiple regions removed
2452 updateRegionEditPanel();
2453 reloadChapterList();
2454 editLockedRegion(lockTemplate);
2455 } else { console.log("no region selected") }
2456 }
2457 }
2458
2459 function regionsMatch(reg1, reg2) {
2460 if (reg1 && reg2 && reg1.start == reg2.start && reg1.end == reg2.end) return true;
2461 return false;
2462 }
2463
2464 function isCurrentRegion(region) {
2465 if (regionsMatch(currentRegion, region)) return true;
2466 return false;
2467 }
2468
2469 function isInCurrentRegions(region) {
2470 if (currentRegions != []) {
2471 for (let i = 0; i < currentRegions.length; i++) {
2472 if (currentRegions[i].start == region.start && currentRegions[i].end == region.end) {
2473 return true;
2474 }
2475 }
2476 }
2477 return false;
2478 }
2479
2480 function getCurrentRegionIndex() { // returns the index of currently selected region
2481 for (let i = 0; i < currSpeakerSet.tempSpeakerObjects.length; i++) {
2482 if (isCurrentRegion(currSpeakerSet.tempSpeakerObjects[i].region)) { return i }
2483 }
2484 return -1;
2485 }
2486
2487 function getCurrentRegionsIndexes() { // returns the indexes of currently selected regions
2488 let indexes = [];
2489 for (let i = 0; i < currSpeakerSet.tempSpeakerObjects.length; i++) {
2490 if (isInCurrentRegions(currSpeakerSet.tempSpeakerObjects[i].region)) { indexes.push(i) }
2491 }
2492 return indexes;
2493 }
2494
2495 function removeCurrentRegion() { // removes current region, regions and bound markers
2496 currentRegion = {speaker: '', start: '', end: ''};
2497 currentRegions = [];
2498 removeRegionBounds();
2499 hoverSpeaker.innerHTML = "";
2500 }
2501
2502 function getRegionsWithSpeaker(speaker) { // returns all regions with the given speaker name
2503 let out = [];
2504 for (let i = 0; i < currSpeakerSet.tempSpeakerObjects.length; i++) {
2505 if (currSpeakerSet.tempSpeakerObjects[i].speaker === speaker) { out.push(currSpeakerSet.tempSpeakerObjects[i]) }
2506 }
2507 return out;
2508 }
2509
2510 function sortSpeakerObjectsByStart(speakerOb) { // sorts the speaker object array by start time
2511 return speakerOb.sort(function(a,b) {
2512 return a.start - b.start;
2513 });
2514 }
2515
2516 /**
2517 * Changes the associated speaker name of a region, updating the speaker set
2518 */
2519 function speakerChange() {
2520 const newSpeaker = speakerInput.value;
2521 clearChapterSearch();
2522 if (newSpeaker && newSpeaker.trim() != "") {
2523 speakerInput.style.outline = "2px solid transparent";
2524 if (getCurrentRegionIndex() != -1) { // if a region is selected
2525 const chaps = chapters.childNodes;
2526 if (!currSpeakerSet.uniqueSpeakers.includes(newSpeaker)) { currSpeakerSet.uniqueSpeakers.push(newSpeaker) }
2527 if (currentRegions && currentRegions.length < 1) { // single change
2528 currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].speaker = newSpeaker; // update corrosponding speakerObject speaker
2529 currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.attributes.label.innerText = newSpeaker;
2530 chaps[getCurrentRegionIndex()].firstChild.textContent = newSpeaker; // update chapter text
2531 } else if (currentRegions && currentRegions.length > 1) { // multiple changes
2532 for (idx of getCurrentRegionsIndexes()) {
2533 currSpeakerSet.tempSpeakerObjects[idx].speaker = newSpeaker;
2534 currSpeakerSet.tempSpeakerObjects[idx].region.attributes.label.innerText = newSpeaker;
2535 chaps[idx].firstChild.textContent = newSpeaker;
2536 }
2537 }
2538 currentRegion.speaker = newSpeaker;
2539 chapterLeave(getCurrentRegionIndex()); // update region bound text
2540 editsMade = true;
2541 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "speaker-change", getCurrentRegionIndex(), getCurrentRegionsIndexes());
2542 editLockedRegion(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()]);
2543 let regElement = currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element;
2544 if (regElement.getElementsByClassName("region-conflict").length > 0 && !newSpeaker.includes("conflict")) {
2545 regElement.getElementsByClassName("region-conflict")[0].remove();
2546 } else if (regElement.getElementsByClassName("region-conflict").length == 0 && newSpeaker.includes("conflict")) {
2547 drawConflictMarker(regElement);
2548 }
2549 updateChapterConflictIcons();
2550 checkCSVForConflict(selectedVersions[currSpeakerSet.isSecondary ? 1 : 0], "", currSpeakerSet.tempSpeakerObjects);
2551 } else { console.log("no region selected") }
2552 } else { console.log("no text in speaker input"); speakerInput.style.outline = "2px solid firebrick"; }
2553 }
2554
2555 function updateChapterConflictIcons() { // disabled: chapter names referenced in too many places to easily replace text with icons
2556 if (false) {
2557 document.querySelectorAll('.conflict-hover-icon').forEach(e => e.remove());
2558 for (const chap of chapters.childNodes) {
2559 const speakerName = chap.getElementsByClassName("speakerName")[0].innerText;
2560 if (speakerName.includes("dur_lock:")) {
2561 let img = document.createElement("img");
2562 img.className = "conflict-hover-icon";
2563 img.src = interface_bootstrap_images + "clock.svg";
2564 img.title = "This region represents a start/stop time conflict";
2565 chap.insertBefore(img, chap.childNodes[2]);
2566 }
2567 if (speakerName.includes("spkr_lock:")) {
2568 let img = document.createElement("img");
2569 img.className = "conflict-hover-icon";
2570 img.src = interface_bootstrap_images + "person.svg";
2571 img.title = "This region represents a speaker name conflict";
2572 chap.insertBefore(img, chap.childNodes[2]);
2573 }
2574 // chap.getElementsByClassName("speakerName")[0].innerText = speakerName.replace("dur_lock:", "").replace("spkr_lock:", "");
2575 }
2576 }
2577 }
2578
2579 function speakerInputUnfocused() {
2580 prevUndoState = "";
2581 if (speakerInput.value == "" && !speakerInput.classList.contains("disabled")) {
2582 speakerInput.style.outline = "2px solid firebrick";
2583 window.alert("Speaker input cannot be left empty. Please enter a speaker name.");
2584 setTimeout(() => speakerInput.focus(), 10); // timeout needed otherwise input isn't selected
2585 } else speakerInput.style.outline = "2px transparent";
2586 }
2587
2588 /**
2589 * Selects all (or reverts select-all) regions matching any of the currently selected speaker names
2590 * @param {boolean} skipUndoState Whether or not to skip the addition of an undo state
2591 */
2592 function selectAllCheckboxChanged(skipUndoState) { // "Change all" toggled
2593 if (changeAllCheckbox.checked) {
2594 // **** Have decided to suppress auto zoom out on select all
2595 // if (!isZooming) {
2596 // tempZoomSave = zoomSlider.value;
2597 // zoomTo(1); // zoom out to encompass all selected regions
2598 // }
2599 let uniqueSelectedSpeakers;
2600 if (currentRegions && currentRegions.length > 0) { // if more than one region selected
2601 uniqueSelectedSpeakers = [... new Set(currentRegions.map(a => a.speaker))]; // gets unique speakers in currentRegions
2602 uniqueSelectedSpeakers.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
2603 } else uniqueSelectedSpeakers = [currentRegion.speaker];
2604 currentRegions = [];
2605 for (const speaker of uniqueSelectedSpeakers) {
2606 for (const region of getRegionsWithSpeaker(speaker)) {
2607 currentRegions.push(region);
2608 region.region.update({color: "rgba(255,50,50,0.5)"});
2609 }
2610 }
2611 } else {
2612 // **** Have decided to suppress auto zoom out on select all
2613 // if (!isZooming) {
2614 // zoomTo(tempZoomSave / 4); // zoom back in to previous level
2615 // }
2616 currentRegions = []; // this will lose track of previously selected region*s*
2617 }
2618 reloadRegionsAndChapters();
2619 if (!skipUndoState) addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "selectAllChange", getCurrentRegionIndex(), getCurrentRegionsIndexes());
2620 }
2621
2622 function enableStartEndInputs() { // removes the 'disabled' tag from all time inputs
2623 for (idx in startTimeInput.childNodes) { startTimeInput.childNodes[idx].disabled = false }
2624 for (idx in endTimeInput.childNodes) { endTimeInput.childNodes[idx].disabled = false }
2625 }
2626
2627 function disableStartEndInputs() { // adds the 'disabled' tag to all time inputs
2628 for (idx in startTimeInput.childNodes) { startTimeInput.childNodes[idx].value = 0; startTimeInput.childNodes[idx].disabled = true; }
2629 for (idx in endTimeInput.childNodes) { endTimeInput.childNodes[idx].value = 0; endTimeInput.childNodes[idx].disabled = true; }
2630 }
2631
2632 /**
2633 * Zooms wavesurfer waveform to destination zoom level, used in select all function
2634 * @param {number} dest Destination zoom level
2635 */
2636 function zoomTo(dest) {
2637 isZooming = true;
2638 changeAllCheckbox.disabled = true;
2639 let isOut = false;
2640 if (dest == 0) isOut = true;
2641 zoomInterval = setInterval(() => {
2642 const sliderValue = Number(zoomSlider.value) / 4;
2643 if (isOut) {
2644 if (zoomSlider.value > 1) {
2645 if (zoomSlider.value > 50) zoomSlider.value -= 30; // ramp up for finer adjustments
2646 else zoomSlider.stepDown();
2647 wavesurfer.zoom(sliderValue > 1 ? (sliderValue / 4) : 1); // ensure value is greater than 1
2648 } else {
2649 clearInterval(zoomInterval);
2650 isZooming = false;
2651 changeAllCheckbox.disabled = false;
2652 zoomSlider.dispatchEvent(new Event("input"));
2653 }
2654 } else {
2655 if (zoomSlider.value / 4 < dest) {
2656 if (zoomSlider.value > 50) zoomSlider.value += 30; // ramp up for finer adjustments
2657 else zoomSlider.stepUp();
2658 wavesurfer.zoom(sliderValue > 1 ? (sliderValue / 4) : 1); // ensure value is greater than 1
2659 } else {
2660 clearInterval(zoomInterval);
2661 isZooming = false;
2662 changeAllCheckbox.disabled = false;
2663 zoomSlider.dispatchEvent(new Event("input"));
2664 }
2665 }
2666 }, 10); // 10ms interval
2667 }
2668
2669 function toggleSavePopup() { // shows / hides commit popup div
2670 savePopupCommitMsg.value = savePopupCommitMsg.value.trim(); // clears initial whitespace caused by <xsl: text>
2671 if (savePopup.classList.contains("visible")) {
2672 savePopup.classList.remove("visible");
2673 savePopupBG.classList.remove("visible");
2674 } else {
2675 savePopup.classList.add("visible");
2676 savePopupBG.classList.add("visible");
2677 savePopup.children[0].innerText = "Commit changes for: " + selectedVersions[(!dualMode || primaryCaret.src.includes("fill")) ? 0 : 1];
2678 }
2679 }
2680
2681 function saveRegionChanges() { // saves tempSpeakerObjects to speakerObjects
2682 if (!saveButton.classList.contains("disabled")) {
2683 toggleSavePopup();
2684 // old save functionality
2685 // currSpeakerSet.speakerObjects = cloneSpeakerObjectArray(currSpeakerSet.tempSpeakerObjects);
2686 // editsMade = false;
2687 // removeCurrentRegion();
2688 // reloadRegionsAndChapters();
2689 // console.log("saved changes.");
2690 }
2691 }
2692
2693 /**
2694 * Commits changes made to the currently selected set to Greenstone's version history system.
2695 * Firstly increments FLDV, then saves commit message to document's metadata, then sets document's
2696 * associated file to tempSpeakerObjects CSV.
2697 */
2698 function commitChanges() {
2699 if (savePopupCommitMsg.value && savePopupCommitMsg.value.length > 0) {
2700 console.log('committing with message: ' + savePopupCommitMsg.value);
2701 $.ajax({
2702 type: "GET",
2703 url: mod_meta_base_url,
2704 data: { "o": "json", "s1.a": "inc-fldv-nminus1" }
2705 }).then((out) => {
2706 if (out.page.pageResponse.status.code == GSSTATUS_SUCCESS) {
2707 console.log('fldv inc success with status code: ' + out.page.pageResponse.status.code);
2708 ajaxSetCommitMeta();
2709 } else {
2710 console.log('fldv inc ERROR with status code: ' + out.page.pageResponse.status.code);
2711 // TODO output tangible error message from out.page.pageResponse [unsure of property, do same for all two(three?) calls]
2712 }
2713 }, (error) => { console.log("inc-fldv-nminus1 error:\n" + error) });
2714 toggleSavePopup();
2715 } else {
2716 window.alert("Commit message cannot be left empty.");
2717 }
2718 }
2719
2720 function ajaxSetCommitMeta() { // saves commit message to current document's metadata
2721 $.ajax({
2722 type: "GET",
2723 url: mod_meta_base_url,
2724 data: { "o" : "json", "s1.a": "set-archives-metadata", "s1.metaname": "commitmessage", "s1.metavalue": savePopupCommitMsg.value.trim(), "s1.metamode": "override" },
2725 }).then((out) => {
2726 console.log('commit success with status code: ' + out.page.pageResponse.status.code);
2727 if (out.page.pageResponse.status.code == GSSTATUS_SUCCESS) {
2728 ajaxSetAssocFile();
2729 } // TODO else error message
2730 }, (error) => { console.log("commit_msg_url error:"); console.log(error); });
2731 }
2732
2733 function ajaxSetAssocFile() { // sets current document's associated file to tempSpeakerObjects
2734 $.ajax({
2735 type: "POST",
2736 url: gs.xsltParams.library_name,
2737 data: { "o" : "json", "a": "g", "rt": "r", "ro": "0", "s": "ModifyMetadata", "s1.collection": gs.cgiParams.c, "s1.site": gs.xsltParams.site_name, "s1.d": gs.cgiParams.d,
2738 "s1.a": "set-archives-assocfile", "s1.assocname": "structured-audio.csv", "s1.filedata": speakerObjToCSVText() },
2739 }).then((out) => {
2740 console.log('set-archives-assocfile success with status code: ' + out.page.pageResponse.status.code);
2741 resetUndoStates();
2742 }, (error) => { console.log("set_assoc_url error:"); console.log(error); });
2743 }
2744
2745 function speakerObjToCSVText() { // converts tempSpeakerObjects to csv-like string
2746 // SPEAKER, START, END, DURATION_LOCK, SPEAKER_LOCK, GLOBAL_LOCK
2747 const regex = new RegExp("SPEAKER_\\d{2}");
2748 return currSpeakerSet.tempSpeakerObjects.map(item => [item.speaker, item.start, item.end, item.locked || false, !regex.test(item.speaker), !regex.test(item.speaker) || item.locked]).join("\n");
2749 }
2750
2751 function discardRegionChanges(forceDiscard) { // resets tempSpeakerObjects to speakerObjects
2752 if (!discardButton.classList.contains("disabled") || forceDiscard) {
2753 let confirm = false;
2754 if (!forceDiscard) { confirm = window.confirm("Are you sure you want to discard changes?"); }
2755 if (confirm || forceDiscard) {
2756 currSpeakerSet.tempSpeakerObjects = cloneSpeakerObjectArray(currSpeakerSet.speakerObjects);
2757 editsMade = false;
2758 removeCurrentRegion();
2759 resetUndoStates();
2760 reloadRegionsAndChapters();
2761 console.log("discarded changes");
2762 }
2763 }
2764 }
2765
2766 /**
2767 * Redraws edit panel, chapter list and wavesurfer regions from speaker set
2768 */
2769 function reloadRegionsAndChapters() { // redraws edit panel, chapter list, wavesurfer regions
2770 updateRegionEditPanel();
2771 $(".region-top").remove();
2772 $(".region-bottom").remove();
2773 $(".wavesurfer-region").remove();
2774 populateChaptersAndRegions(primarySet);
2775 if (dualMode) {
2776 populateChaptersAndRegions(secondarySet);
2777 currSpeakerSet = primarySet;
2778 }
2779 updateCurrSpeakerSet();
2780 if (editMode && currentRegion && currentRegion.speaker && getCurrentRegionIndex() != -1 && currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element) {
2781 setHoverSpeaker(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element.style.left, currentRegion.speaker);
2782 drawCurrentRegionBounds();
2783 }
2784 if (currentRegions.length < 1) {
2785 removeButton.innerHTML = "Remove Selected Region";
2786 // enableStartEndInputs();
2787 } else {
2788 removeButton.innerHTML = "Remove Selected Regions (x" + currentRegions.length + ")";
2789 const uniqueSelectedSpeakers = [... new Set(currentRegions.map(a => a.speaker))]; // gets unique speakers in currentRegions
2790 uniqueSelectedSpeakers.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
2791 speakerInput.value = uniqueSelectedSpeakers.join(", ");
2792 }
2793 let autocompleteOptions = currSpeakerSet.uniqueSpeakers;
2794 autocompleteOptions.pop();
2795 autocompleteOptions.sort();
2796 $("#speaker-input").autocomplete({
2797 source: autocompleteOptions,
2798 minLength: 2,
2799 close: (event, ui) => {
2800 // updates speaker name on autocomplete dropdown close
2801 speakerChange();
2802 }
2803 });
2804 }
2805
2806 /**
2807 * Handles the change of a region's start or end time, updating hte speaker set
2808 */
2809 function changeStartEndTime(e) { // start/end time input handler
2810 let newStart = getTimeInSecondsFromInput(startTimeInput);
2811 let newEnd = getTimeInSecondsFromInput(endTimeInput);
2812 let duration = Math.floor(wavesurfer.getDuration()); // total duration of current audio
2813 if (getCurrentRegionIndex() != -1) { // if there is a selected region
2814 if (newEnd <= newStart) newStart = newEnd - 1; // when start time > end time, push region forward
2815 if (newEnd <= 0) newEnd = 1;
2816 if (newStart < 0) newStart = 0; // ensures region start doesn't go < 0s
2817 if (newEnd > duration) newEnd = duration; // ensures region start doesn't go > duration
2818
2819 setInputInSeconds(startTimeInput, newStart);
2820 setInputInSeconds(endTimeInput, newEnd);
2821
2822 let currRegIdx = getCurrentRegionIndex();
2823 currSpeakerSet.tempSpeakerObjects[currRegIdx].start = newStart;
2824 currSpeakerSet.tempSpeakerObjects[currRegIdx].end = newEnd;
2825 currSpeakerSet.tempSpeakerObjects[currRegIdx].region.update({start: newStart, end: newEnd});
2826 currentRegion.start = newStart;
2827 currentRegion.end = newEnd;
2828 editsMade = true;
2829 handleSameSpeakerOverlap(currRegIdx, currSpeakerSet);
2830 addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "change-time", getCurrentRegionIndex());
2831 editLockedRegion(currSpeakerSet.tempSpeakerObjects[currRegIdx]);
2832 } else {
2833 console.log("no region selected");
2834 setInputInSeconds(startTimeInput, 0);
2835 setInputInSeconds(endTimeInput, 0);
2836 }
2837 }
2838
2839 /**
2840 * Calculates time in seconds of start or end time input group
2841 * @param {element} input Element of time input groups: hh:mm:ss
2842 * @returns {int} Time in seconds
2843 */
2844 function getTimeInSecondsFromInput(input) {
2845 let hours = input.children[0].valueAsNumber;
2846 let mins = input.children[1].valueAsNumber;
2847 let secs = input.children[2].valueAsNumber;
2848 return (hours * 3600) + (mins * 60) + secs;
2849 }
2850
2851 /**
2852 * Sets the start or end time element group inputs
2853 * @param {element} input Element of time input group to be updated
2854 * @param {int} seconds Duration in seconds to be converted into hh:mm:ss
2855 */
2856 function setInputInSeconds(input, seconds) { // sets start or end input time when given seconds
2857 let date = new Date(null);
2858 date.setMilliseconds(seconds * 1000);
2859 input.children[0].value = date.getHours() % 12;
2860 input.children[1].value = date.getMinutes();
2861 input.children[2].value = date.getSeconds() + "." + (Math.ceil(date.getMilliseconds() / 100) * 100);
2862 document.querySelectorAll('input[type=number]').forEach(e => {
2863 e.value = Math.round(e.valueAsNumber * 10) / 10; // to 1dp
2864 if (e.classList.contains("seconds") && !e.value.includes(".")) { e.value = e.value + ".0"; }
2865 else if (e.value.length === 1){ e.value = '0' + e.value; }// 0 padded on left
2866 });
2867 }
2868
2869 /**
2870 * Adds a new undo state to the global undo state list
2871 * @param {object} state Primary set at current state
2872 * @param {object} secState Secondary set at current state
2873 * @param {boolean} isSec Whether or not current change was made to primary (false) or secondary (true) set
2874 * @param {boolean} dualMode Whether or not audio editor was in dual mode when undo state was added
2875 * @param {string} type Type of change e.g "remove", "speaker-change"
2876 * @param {int} currRegIdx Index of currently selected region (for restoration)
2877 * @param {Array} currRegIdxs Index of currently selected regions, if applicable (for restoration)
2878 */
2879 function addUndoState(state, secState, isSec, dualMode, type, currRegIdx, currRegIdxs) { // adds a new state to the undoStates stack
2880 let newState = cloneSpeakerObjectArray(state.tempSpeakerObjects); // clone method removes references
2881 let newSecState = cloneSpeakerObjectArray(secState.tempSpeakerObjects); // clone method removes references
2882 let changedTrack = (type == "dualModeChange" || type == "selectAllChange") ? "none" : selectedVersions[isSec ? 1 : 0] // sets changedTrack to version name of edited region set
2883 undoButton.classList.remove("disabled");
2884 undoStates = undoStates.slice(0, undoLevel + 1); // trim to current level if undos have already been made
2885 undoStates.push({state: newState, secState: newSecState, isSec: isSec, changedTrack: changedTrack, dualMode: dualMode, currentRegionIndex: currRegIdx, currentRegionIndexes: currRegIdxs, type: type});
2886 if ((type === "change-time" && prevUndoState === "change-time") || (type === "speaker-change" && prevUndoState === "speaker-change")) { // checks if similar change was made previously
2887 undoStates.splice(-2, 1); // remove second-to-last item in undoStates stack (merge last two changes into one to avoid multiple small edits)
2888 prevUndoState = type;
2889 } else undoLevel++;
2890 prevUndoState = type;
2891 redoButton.classList.add("disabled");
2892 for (const item of undoStates) { // remove cyclic object references
2893 item.state = cloneSpeakerObjectArray(item.state);
2894 item.secState = cloneSpeakerObjectArray(item.secState);
2895 }
2896 localStorage.setItem(audioIdentifier, JSON.stringify({ "undoStates": undoStates, "undoLevel": undoLevel }));
2897 }
2898
2899 /**
2900 * Returns to the previous state in the undo state list
2901 */
2902 function undo() {
2903 if (!undoButton.classList.contains("disabled") && editMode) { // ensure there exist states to undo to
2904 clearChapterSearch();
2905 if (undoLevel - 1 < 0) console.log("ran out of undos");
2906 else {
2907 removeCurrentRegion();
2908 let adjustedUndoLevel = undoLevel-1;
2909 if (undoStates[undoLevel].type == "dualModeChange") { // toggle dual mode
2910 dualModeChanged(true);
2911 } else if (undoStates[undoLevel].type == "selectAllChange") { // toggle select all
2912 changeAllCheckbox.checked = !changeAllCheckbox.checked;
2913 selectAllCheckboxChanged(true);
2914 } else {
2915 primarySet.tempSpeakerObjects = cloneSpeakerObjectArray(undoStates[adjustedUndoLevel].state.slice(0)); // slice & clone removes potential references between arrays
2916 if (dualMode && undoStates[adjustedUndoLevel].secState && undoStates[adjustedUndoLevel].secState.length > 0) { // if secondary undoState exists
2917 secondarySet.tempSpeakerObjects = cloneSpeakerObjectArray(undoStates[adjustedUndoLevel].secState.slice(0)); // slice & clone removes potential references between arrays
2918 }
2919 let selectedSpeakerSet;
2920 // handle currentRegion change
2921 if (undoStates[undoLevel] && undoStates[undoLevel].type && undoStates[undoLevel].type == "remove") { // if destination state type is remove
2922 selectedSpeakerSet = (undoStates[undoLevel].isSec) ? secondarySet : primarySet;
2923 if (selectedSpeakerSet.isSecondary) caretClicked("secondary-caret");
2924 else caretClicked("primary-caret");
2925 currentRegion = selectedSpeakerSet.tempSpeakerObjects[undoStates[undoLevel].currentRegionIndex]; // restore previous current state
2926 } else if (undoStates[undoLevel].currentRegionIndex) {
2927 if (!dualMode) selectedSpeakerSet = primarySet;
2928 else {
2929 selectedSpeakerSet = (undoStates[undoLevel-1].isSec) ? secondarySet : primarySet;
2930 if (selectedSpeakerSet.isSecondary) caretClicked("secondary-caret");
2931 else caretClicked("primary-caret");
2932 }
2933 currentRegion = selectedSpeakerSet.tempSpeakerObjects[undoStates[undoLevel].currentRegionIndex];
2934 }
2935 // handle currentRegions restoration
2936 if (undoStates[undoLevel].currentRegionIndexes && undoStates[undoLevel].currentRegionIndexes.length > 1) {
2937 for (const idx of undoStates[undoLevel].currentRegionIndexes) currentRegions.push(currSpeakerSet.tempSpeakerObjects[idx]);
2938 }
2939 }
2940 editsMade = true;
2941 undoLevel--; // decrement undoLevel
2942 reloadRegionsAndChapters();
2943 localStorage.setItem(audioIdentifier, { "undoLevel": undoLevel });
2944 if (undoLevel - 1 < 0) undoButton.classList.add("disabled");
2945 else undoButton.classList.remove("disabled");
2946 }
2947 if (undoLevel < undoStates.length) redoButton.classList.remove("disabled");
2948 }
2949 }
2950
2951 /**
2952 * Moves forward one state in the undo state list
2953 */
2954 function redo() {
2955 if (!redoButton.classList.contains("disabled") && editMode) { // ensure there exist states to redo to
2956 clearChapterSearch();
2957 if (undoLevel + 1 >= undoStates.length) console.log("ran out of redos");
2958 else {
2959 if (undoStates[undoLevel+1].type == "dualModeChange") { // toggle dual mode
2960 dualModeChanged(true);
2961 } else if (undoStates[undoLevel+1].type == "selectAllChange") { // toggle select all
2962 changeAllCheckbox.checked = !changeAllCheckbox.checked;
2963 selectAllCheckboxChanged(true);
2964 } else {
2965 primarySet.tempSpeakerObjects = cloneSpeakerObjectArray(undoStates[undoLevel+1].state.slice(0)); // set primary to new state
2966 secondarySet.tempSpeakerObjects = cloneSpeakerObjectArray(undoStates[undoLevel+1].secState.slice(0)); // set secondary to new state
2967 let selectedSpeakerSet;
2968 // handle currentRegion change
2969 removeCurrentRegion();
2970 if (undoLevel+2 < undoStates.length) {
2971 if (undoStates[undoLevel+2] && undoStates[undoLevel+2].type && undoStates[undoLevel+2].type == "remove") {
2972 selectedSpeakerSet = (undoStates[undoLevel+2].isSec) ? secondarySet : primarySet;
2973 if (selectedSpeakerSet.isSecondary) caretClicked("secondary-caret");
2974 else caretClicked("primary-caret");
2975 currentRegion = selectedSpeakerSet.tempSpeakerObjects[undoStates[undoLevel+2].currentRegionIndex];
2976 } else {
2977 selectedSpeakerSet = (undoStates[undoLevel+1].isSec) ? secondarySet : primarySet;
2978 if (selectedSpeakerSet.isSecondary) caretClicked("secondary-caret");
2979 else caretClicked("primary-caret");
2980 currentRegion = selectedSpeakerSet.tempSpeakerObjects[undoStates[undoLevel+1].currentRegionIndex];
2981 }
2982 if (undoStates[undoLevel+1].currentRegionIndexes && undoStates[undoLevel+1].currentRegionIndexes.length > 1) {
2983 for (const idx of undoStates[undoLevel+1].currentRegionIndexes) currentRegions.push(currSpeakerSet.tempSpeakerObjects[idx]);
2984 }
2985 }
2986 }
2987 editsMade = true;
2988 reloadRegionsAndChapters();
2989 undoLevel++; // increment undoLevel
2990 localStorage.setItem(audioIdentifier, { "undoLevel": undoLevel });
2991 if (undoLevel + 1 > undoStates.length - 1) redoButton.classList.add("disabled");
2992 else redoButton.classList.remove("disabled");
2993 }
2994 if (undoLevel < undoStates.length) undoButton.classList.remove("disabled");
2995 }
2996 }
2997
2998 function resetUndoStates() { // clear undo history
2999 undoStates = [{state: cloneSpeakerObjectArray(primarySet.tempSpeakerObjects), secState: cloneSpeakerObjectArray(secondarySet.tempSpeakerObjects)}];
3000 undoLevel = 0;
3001 localStorage.removeItem(audioIdentifier);
3002 undoButton.classList.add("disabled");
3003 redoButton.classList.add("disabled");
3004 }
3005
3006 function waveformScrolled() { // waveform scroll handler
3007 if (currentRegion.speaker && getCurrentRegionIndex() != -1) { // updates region bound markers if selected region exists
3008 setHoverSpeaker(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element.style.left, currentRegion.speaker);
3009 drawCurrentRegionBounds();
3010 }
3011 if (document.getElementById('new-canvas')) { document.getElementById('new-canvas').style.left = "-" + wave.scrollLeft + 'px' } // update placeholder waveform scroll position
3012 }
3013
3014 function drawCurrentRegionBounds() { // draws bounds of current region
3015 removeRegionBounds();
3016 let currIndexes = getCurrentRegionsIndexes();
3017 if (getCurrentRegionIndex() != -1) drawRegionBounds(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region, wave.scrollLeft, "FireBrick");
3018 for (let i = 0; i < currIndexes.length; i++) {
3019 drawRegionBounds(currSpeakerSet.tempSpeakerObjects[currIndexes[i]].region, wave.scrollLeft, "FireBrick");
3020 }
3021 }
3022
3023 /**
3024 * Draws bounding 'n' above hovered or selected region
3025 * @param {object} region Region to have bound drawn for
3026 * @param {number} scrollPos Scroll position of div, used to offset draw position
3027 * @param {string} colour Colour to draw bound (black and FireBrick are used)
3028 */
3029 function drawRegionBounds(region, scrollPos, colour) { // draws on canvas to show bounds of hovered/selected region
3030 const hoverSpeakerCanvas = document.createElement("canvas");
3031 hoverSpeakerCanvas.id = "hover-speaker-canvas";
3032 hoverSpeakerCanvas.classList.add("region-bounds");
3033 hoverSpeakerCanvas.width = audioContainer.clientWidth; // max width of drawn bounds
3034 const ctx = hoverSpeakerCanvas.getContext("2d");
3035 // ctx.translate(0.5, 0.5); // fixes lineWidth inconsistency
3036 ctx.lineWidth = 1;
3037 if (colour == "FireBrick") ctx.lineWidth = 3;
3038 if (currentRegions && currentRegions.length < 1 && isCurrentRegion(region) && editMode) {
3039 colour = "FireBrick";
3040 ctx.lineWidth = 3;
3041 }
3042 ctx.strokeStyle = colour;
3043 ctx.beginPath();
3044 ctx.moveTo(parseInt(region.element.style.left.slice(0, -2)) - scrollPos, 28);
3045 ctx.lineTo(parseInt(region.element.style.left.slice(0, -2)) - scrollPos, 20);
3046 ctx.lineTo(parseInt(region.element.style.left.slice(0, -2)) + parseInt(region.element.style.width.slice(0, -2)) - scrollPos, 20);
3047 ctx.lineTo(parseInt(region.element.style.left.slice(0, -2)) + parseInt(region.element.style.width.slice(0, -2)) - scrollPos, 28);
3048 ctx.stroke();
3049 audioContainer.prepend(hoverSpeakerCanvas);
3050 }
3051
3052 function removeRegionBounds() { // remove all region bound markers
3053 let canvases = document.getElementsByClassName('region-bounds');
3054 while (canvases[0]) canvases[0].parentNode.removeChild(canvases[0]);
3055 }
3056
3057 function updateCurrSpeakerSet() { // updates 'currSpeakerSet' var
3058 if (primaryCaret.src.includes("fill")) currSpeakerSet = primarySet;
3059 else if (secondaryCaret.src.includes("fill")) currSpeakerSet = secondarySet;
3060 }
3061
3062 function cloneSpeakerObjectArray(inputArray) { // clones speakerObjectArray without references (wavesurfer regions)
3063 let output = [];
3064 for (let i = 0; i < inputArray.length; i++) {
3065 output.push({ speaker: inputArray[i].speaker, start: inputArray[i].start, end: inputArray[i].end, locked: (inputArray[i].locked === "true" || inputArray[i].locked === true) });
3066 }
3067 return output;
3068 }
3069
3070 function flashChapters() { // flashes chapters a lighter colour momentarily to indicate an update/change
3071 chapters.style.backgroundColor = "rgb(66, 84, 88)";
3072 setTimeout(() => chapters.style.backgroundColor = backgroundColour, 500);
3073 }
3074
3075 /** Fullscreen onChange handler, increases waveform height & adjusts padding/margin */
3076 function fullscreenChanged() {
3077 if (!audioContainer.classList.contains("fullscreen")) {
3078 audioContainer.classList.add("fullscreen");
3079 wavesurfer.setHeight(fullscreenWaveformHeight); // increase waveform height
3080 caretContainer.style.paddingLeft = "2rem";
3081 caretContainer.style.height = wavesurfer.getHeight() + "px"; // set height to waveform height
3082 audioContainer.prepend(caretContainer); // attach to audioContainer (otherwise doesn't show due to AC being fullscreen)
3083 } else {
3084 audioContainer.classList.remove("fullscreen");
3085 wavesurfer.setHeight(waveformHeight);
3086 caretContainer.style.paddingLeft = "0";
3087 caretContainer.style.height = wavesurfer.getHeight() + "px";
3088 audioContainer.parentElement.prepend(caretContainer); // move back up in DOM hierarchy
3089 }
3090 setTimeout(() => { // ensures waveform shows
3091 zoomOutButton.click();
3092 zoomInButton.click();
3093 }, 250);
3094 }
3095
3096 /** Enables / disables the fullscreen view of audio player / editor */
3097 function toggleFullscreen() {
3098 if ((document.fullscreenElement && document.fullscreenElement !== null) ||
3099 (document.webkitFullscreenElement && document.webkitFullscreenElement !== null) ||
3100 (document.mozFullScreenElement && document.mozFullScreenElement !== null) ||
3101 (document.msFullscreenElement && document.msFullscreenElement !== null)) {
3102 document.exitFullscreen();
3103 } else {
3104 if (audioContainer.requestFullscreen) {
3105 audioContainer.requestFullscreen();
3106 } else if (audioContainer.webkitRequestFullscreen) { /* Safari */
3107 audioContainer.webkitRequestFullscreen();
3108 } else if (audioContainer.msRequestFullscreen) { /* IE11 */
3109 audioContainer.msRequestFullscreen();
3110 }
3111 }
3112 }
3113}
3114
3115/**
3116 * Formats seconds to hh:mm:ss
3117 * @param {number} duration
3118 * @returns {string} Time in hh:mm:ss format
3119 */
3120function formatAudioDuration(duration) {
3121 // console.log('duration: ' + duration);
3122 let [hrs, mins, secs, ms] = duration.replace(".", ":").split(":");
3123 return hrs + ":" + mins + ":" + secs;
3124}
Note: See TracBrowser for help on using the repository browser.