Changeset 37588


Ignore:
Timestamp:
2023-03-28T16:17:01+13:00 (13 months ago)
Author:
davidb
Message:

refinement and expansion of the audio diarization editor

File:
1 edited

Legend:

Unmodified
Added
Removed
  • main/trunk/greenstone3/web/interfaces/default/js/utility_scripts.js

    r37287 r37588  
    293293var wavesurfer;
    294294
     295/**
     296 * @param audio input audio file
     297 * @param sectionData diarization data (.csv)
     298 */
    295299function 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
    296304   let editMode = false;
    297305   let currentRegion = {speaker: '', start: '', end: ''};
    298306   let currentRegions = [];
    299307
    300    // let speakerObjects = [];
    301    // let tempSpeakerObjects = [];
    302    // let uniqueSpeakers;
    303    const inputFile = sectionData;
    304308   let itemType;
    305309
    306310   let dualMode = false;
    307311   let secondaryLoaded = false;
     312   let selectedVersions = ['current'];
    308313
    309314   let waveformCursorX = 0;
     
    322327   let isZooming;
    323328
     329   let canvasImages = {}; // stores canvas images of each version for fast loading from cache
     330
    324331   let accentColour = "#66d640";
    325332   // let accentColour = "#F8C537";
     
    328335   let regionColourSet = [];
    329336
     337
    330338   let waveformContainer = document.getElementById("waveform");
     339   let waveformSpinner = document.getElementById('waveform-blocker');
     340   let loader = document.getElementById('waveform-loader');
     341   let initialLoad = true;
    331342   
    332343   wavesurfer = WaveSurfer.create({ // wavesurfer options
     344      autoCenterImmediately: true,
    333345      container: waveformContainer,
    334       backend: "MediaElement",
    335       backgroundColor: "rgb(40, 54, 58)",
    336       // backgroundColor: "rgb(24, 36, 39)",
     346      backend: "WebAudio",
     347      // backgroundColor: "rgb(40, 54, 58)",
     348      backgroundColor: "rgb(29, 43, 47)",
    337349      waveColor: "white",
    338350      progressColor: accentColour,
    339351      // progressColor: "grey",
    340       // barWidth: 2,
    341       barHeight: 1.2,
     352      // barWidth: 1,
     353      // barHeight: 1.2,
    342354      // barGap: 2,
    343355      // barRadius: 1,
     356      height: 140,
    344357      cursorColor: 'black',
    345       cursorWidth: 2,
    346       normalize: true, // normalizes by maximum peak
     358      // maxCanvasWidth: 32000,
     359      minPxPerSec: 15, // default 20
     360      partialRender: true, // use the PeakCache to improve rendering speed of large waveforms
     361      pixelRatio: 1, // 1 results in faster rendering
     362      scrollParent: true,
    347363      plugins: [
    348364         WaveSurfer.regions.create({
     
    356372            secondaryFontColor: "white",
    357373            notchPercentHeight: "0",
    358             fontSize: "12"
     374            fontSize: "12",
     375            // zoomDebounce: 30,
     376            fontFamily: "Courier New"
    359377         }),
    360378         WaveSurfer.cursor.create({
     
    372390   });
    373391
    374    wavesurfer.load(audio);
    375 
    376    // wavesurfer events
    377 
    378    wavesurfer.on('region-click', handleRegionClick);
    379 
    380    function handleRegionClick(region, e) {
    381       contextMenu.classList.remove('visible');
    382       e.stopPropagation();
    383       if (!editMode) { // play region audio on click
    384          wavesurfer.play(region.start); // plays from start of region
    385       } else { // select or deselect current region
    386          if (region.element.classList.contains("region-top")) {
    387             currSpeakerSet = primarySet;
    388             swapCarets(true);
    389          } else if (region.element.classList.contains("region-bottom")) {
    390             currSpeakerSet = secondarySet;
    391             swapCarets(false);
    392          }
    393          prevUndoState = "";
    394 
    395          if (!e.ctrlKey && !e.shiftKey) {
    396             currentRegions = [];
    397             if (getCurrentRegionIndex() != -1 && isCurrentRegion(region)) {
    398                // removeCurrentRegion(); // deselect current region on click
    399             } else {
    400                currentRegion = region;
    401                currentRegion.speaker = currentRegion.attributes.label.innerText;
    402                region.play(); // start and stop to move play cursor to beginning of region
    403                wavesurfer.playPause();
    404             }
    405          } else if (e.ctrlKey) { // control was held during click
    406             if (currentRegions.length == 0 && isCurrentRegion(region)) {
    407                removeCurrentRegion();
    408             } else if (getCurrentRegionIndex() != -1 && isInCurrentRegions(region)) {
    409                const removeIndex = getIndexInCurrentRegions(region);
    410                if (removeIndex != -1) currentRegions.splice(removeIndex, 1);
    411                if (currentRegions.length > 0 && isCurrentRegion(region)) { // change current region if removed
    412                   currentRegion = currentRegions[0];
    413                }
    414             } else {
    415                if (currentRegions.length < 1) currentRegions.push(currentRegion);
    416                if (getIndexInCurrentRegions(region) == -1) currentRegions.push(region); // add if it doesn't already exist
    417                currentRegion = region;
    418                currentRegion.speaker = currentRegion.attributes.label.innerText;
    419                region.play();
    420                wavesurfer.playPause();
    421             }
    422             if (currentRegions.length == 1)  currentRegions = []; // clear selected regions if there is only one
    423          } else if (e.shiftKey) { // shift was held during click
    424             clearChapterSearch();
    425             if (getCurrentRegionIndex() != -1 && getIndexOfRegion(region) != -1) {
    426                if (currentRegions && currentRegions.length > 0) {
    427                   if (Math.max(...getCurrentRegionsIndexes()) < getIndexOfRegion(region)) { // shifting forwards / down
    428                      currentRegions = currSpeakerSet.tempSpeakerObjects.slice(Math.min(...getCurrentRegionsIndexes()), getIndexOfRegion(region)+1);
    429                   } else { // shifting backwards / up
    430                      currentRegions = currSpeakerSet.tempSpeakerObjects.slice(getIndexOfRegion(region), Math.max(...getCurrentRegionsIndexes())+1);
    431                   }
    432                } else {
    433                   if (getCurrentRegionIndex() < getIndexOfRegion(region)) { // shifting forwards / down
    434                      currentRegions = currSpeakerSet.tempSpeakerObjects.slice(getCurrentRegionIndex(), getIndexOfRegion(region)+1);
    435                   } else { // shifting backwards / up
    436                      currentRegions = currSpeakerSet.tempSpeakerObjects.slice(getIndexOfRegion(region), getCurrentRegionIndex()+1);
    437                   }
    438                }
    439             }
    440          }
    441          if (changeAllCheckbox.checked) { currentRegions = getRegionsWithSpeaker(currentRegion.speaker) }
    442          reloadRegionsAndChapters();
    443       }
    444    }
    445 
    446    function getIndexInCurrentRegions(region) {
    447       for (const reg of currentRegions) {
    448          const regSpeaker = reg.attributes ? reg.attributes.label.innerText : reg.speaker;
    449          if (reg.start == region.start && reg.end == region.end && regSpeaker == region.attributes.label.innerText) {
    450             return currentRegions.indexOf(reg);
    451          }
    452       }
    453       return -1;
    454    }
    455 
    456    function getIndexOfRegion(region) {
    457       for (const reg of currSpeakerSet.tempSpeakerObjects) {
    458          if (reg.start == region.start && reg.end == region.end && reg.speaker == region.attributes.label.innerText) {
    459             return currSpeakerSet.tempSpeakerObjects.indexOf(reg);
    460          }
    461       }
    462       return -1;
    463    }
    464 
    465    wavesurfer.on('region-mouseenter', function(region) { // region hover effects
    466       if (!mouseDown) {
    467          handleRegionColours(region, true);
    468          setHoverSpeaker(region.element.style.left, region.attributes.label.innerText);
    469          if (!isInCurrentRegions(region)) {
    470             removeRegionBounds();
    471             drawRegionBounds(region, wave.scrollLeft, "black");
    472          }
    473          if (isCurrentRegion(region) && editMode) drawRegionBounds(region, wave.scrollLeft, "FireBrick");
    474       }
    475    });
    476 
    477    function setHoverSpeaker(offset, name) {
    478       hoverSpeaker.innerHTML = name; 
    479       let newOffset = parseInt(offset.slice(0, -2)) - wave.scrollLeft;
    480       // if (newOffset < 0) newOffset = 0;
    481       hoverSpeaker.style.marginLeft = newOffset + "px";
    482    }
    483 
    484    wavesurfer.on('region-mouseleave', function(region) {
    485       hoverSpeaker.innerHTML = "";
    486       if (!mouseDown) {
    487          if (!(wavesurfer.getCurrentTime() <= region.end && wavesurfer.getCurrentTime() >= region.start)) handleRegionColours(region, false);
    488          if (!editMode) hoverSpeaker.innerHTML = "";
    489          removeRegionBounds();
    490          if (currentRegion.speaker && getCurrentRegionIndex() != -1) {
    491             setHoverSpeaker(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element.style.left, currentRegion.speaker);
    492             drawCurrentRegionBounds();
    493          }
    494       }
    495    });
    496    wavesurfer.on('region-in', function(region) {
    497       // handleRegionColours(region, true);
    498       if (itemType == "chapter" && Array.from(chapters.children)[getIndexOfRegion(region)]) {
    499          Array.from(chapters.children)[getIndexOfRegion(region)].scrollIntoView({ 
    500             behavior: "smooth",
    501             block: "nearest"
    502          });
    503       }
    504    });
    505    wavesurfer.on('region-out', function(region) { handleRegionColours(region, false) });
    506    wavesurfer.on('region-update-end', handleRegionEdit); // end of click-drag event
    507    wavesurfer.on('region-updated', handleRegionSnap);
    508 
    509    let loader = document.createElement("span"); // loading audio element
    510    loader.innerHTML = "Loading audio";
    511    loader.id = "waveform-loader";
    512    document.querySelector("#waveform wave").prepend(loader);
    513 
    514    wavesurfer.on('waveform-ready', function() { // retrieve regions once waveforms have loaded
    515       if (inputFile.endsWith("csv")) { // diarization if csv
    516          itemType = "chapter";
    517          if (localStorage.getItem('undoStates') && localStorage.getItem('undoLevel')) {
    518             console.log('-- Loading regions from localStorage --');
    519             undoStates = JSON.parse(localStorage.getItem('undoStates'));
    520             undoLevel = JSON.parse(localStorage.getItem('undoLevel'));
    521             primarySet.tempSpeakerObjects = undoStates[undoLevel].state;
    522             primarySet.uniqueSpeakers = [];
    523             for (const item of primarySet.tempSpeakerObjects) {
    524                if (!primarySet.uniqueSpeakers.includes(item.speaker)) primarySet.uniqueSpeakers.push(item.speaker);
    525             }
    526             populateChapters(primarySet);
    527             if (undoStates[undoLevel].secState && undoStates[undoLevel].secState.length > 0) {
    528                secondarySet.tempSpeakerObjects = undoStates[undoLevel].secState;
    529                secondarySet.uniqueSpeakers = [];
    530                for (const item of secondarySet.tempSpeakerObjects) {
    531                   if (!secondarySet.uniqueSpeakers.includes(item.speaker)) secondarySet.uniqueSpeakers.push(item.speaker);
    532                }
    533                secondaryLoaded = true;
    534                // editButton.click(); // open edit panel and enable dual mode if secondary set was previously altered
    535                // dualModeCheckbox.checked = true;
    536                // dualModeChanged(true);
    537             }
    538             updateRegionEditPanel();
    539          } else {
    540             loadCSVFile(inputFile, ["speaker", "start", "end"], primarySet);
    541             dualModeCheckbox.checked = true;
    542             dualModeChanged(true);
    543            
    544             setTimeout(()=>{
    545                dualModeCheckbox.checked = false;
    546                dualModeChanged(true);
    547             }, 150)
    548          }
    549       } else if (inputFile.endsWith("json")) { // transcription if json
    550          itemType = "word";
    551          loadJSONFile(inputFile);
    552       } else {                     
    553          console.log("Filetype of " + inputFile + " not supported.")
    554       }
    555      
    556       loader.remove(); // remove load text
    557       chapters.style.cursor = "default"; // remove load cursor
    558       wave.className = "audio-scroll";
    559       drawVersionNames(); // draw version names if editPanel is expanded
    560    });
    561    
    562    function downloadURI(loc, name) {
    563       let link = document.createElement("a");
    564       link.download = name;
    565       link.href = loc;
    566       link.click();
    567    }
    568 
    569392   // toolbar elements & event handlers
    570393   const audioContainer = document.getElementById("audioContainer");
    571394   const dualModeCheckbox = document.getElementById("dual-mode-checkbox");
    572395   const wave = document.getElementsByTagName("wave")[0];
     396   const caretContainer = document.getElementById("caret-container");
    573397   const primaryCaret = document.getElementById("primary-caret");
    574398   const secondaryCaret = document.getElementById("secondary-caret");
     
    602426   const hoverSpeaker = document.getElementById("hover-speaker");
    603427   const contextMenu = document.getElementById("context-menu");
    604    const contextDelete = document.getElementById("context-menu-delete");
    605428   const contextReplace = document.getElementById("context-menu-replace");
    606429   const contextOverdub = document.getElementById("context-menu-overdub");
    607    // const contextCopy = document.getElementById("context-menu-copy");
    608    const contextSave = document.getElementById("context-menu-save");
    609    const dualModeMenuButton = document.getElementById("dual-mode-menu-button");
    610    const dualModeMenu = document.getElementById("dual-mode-menu");
     430   const contextLock = document.getElementById("context-menu-lock");
     431   const contextDelete = document.getElementById("context-menu-delete");
     432   const timelineMenu = document.getElementById("timeline-menu");
     433   const timelineMenuButton = document.getElementById("timeline-menu-button");
     434   const timelineMenuHide = document.getElementById("timeline-menu-hide");
     435   const timelineMenuDualMode = document.getElementById("timeline-menu-dualmode");
     436   const timelineMenuRegionConflict = document.getElementById("timeline-menu-region");
     437   const timelineMenuSpeakerConflict = document.getElementById("timeline-menu-speaker");
     438   const versionSelectMenu = document.getElementById('version-select-menu');
     439   const versionSelectLabels = document.querySelectorAll(".track-arrow");
     440   const savePopup = document.getElementById("save-popup");
     441   const savePopupBG = document.getElementById("save-popup-bg");
     442   const savePopupCancel = document.getElementById("save-popup-cancel");
     443   const savePopupCommit = document.getElementById("save-popup-commit");
     444   const savePopupCommitMsg = document.getElementById("commit-message");
    611445
    612446   audioContainer.addEventListener('fullscreenchange', (e) => { fullscreenChanged() });
     
    631465   editButton.addEventListener("click", toggleEditMode);
    632466   downloadButton.addEventListener("click", () => { downloadURI(audio, audio.split(".dir/")[1]) });
    633    muteButton.addEventListener("click", () => { wavesurfer.toggleMute() });
     467   muteButton.addEventListener("click", () => {
     468      if (volumeSlider.value == 0) wavesurfer.setMute(false)
     469      else wavesurfer.setMute(true)
     470   });
    634471   volumeSlider.style["accent-color"] = accentColour;
    635472   fullscreenButton.addEventListener("click", toggleFullscreen);
     
    640477   createButton.addEventListener("click", createNewRegion);
    641478   removeButton.addEventListener("click", removeRegion);
    642    discardButton.addEventListener("click", discardRegionChanges);
     479   discardButton.addEventListener("click", () => discardRegionChanges(false));
    643480   undoButton.addEventListener("click", undo);
    644481   redoButton.addEventListener("click", redo);
    645482   saveButton.addEventListener("click", saveRegionChanges);
    646    document.addEventListener('click', () => contextMenu.classList.remove('visible'));
     483   document.addEventListener('click', documentClicked);
    647484   document.addEventListener('mouseup', () => mouseDown = false);
    648485   document.addEventListener('mousedown', (e) => { if (e.target.id !== "create-button") newRegionOffset = 0 }); // resets new region offset on click
     
    651488      e.onblur = () => { prevUndoState = "" };
    652489   });
    653    contextDelete.addEventListener("click", removeRightClicked);
    654490   contextReplace.addEventListener("click", replaceSelected);
    655491   contextOverdub.addEventListener("click", overdubSelected);
    656    // contextCopy.addEventListener("click", copySelected);
    657    contextSave.addEventListener("click", saveSelected);
    658    dualModeMenuButton.addEventListener("click", dualModeMenuToggle);
    659    dualModeMenuButton.addEventListener("click", dualModeMenuToggle);
    660 
     492   contextLock.addEventListener("click", toggleLockSelected);
     493   contextDelete.addEventListener("click", removeRightClicked);
     494   timelineMenu.addEventListener("click", e => e.stopPropagation());
     495   timelineMenuButton.addEventListener("click", timelineMenuToggle);
     496   timelineMenuHide.addEventListener("click", timelineMenuHideClicked);
     497   timelineMenuDualMode.addEventListener("click", () => { dualModeChanged() });
     498   timelineMenuRegionConflict.addEventListener("click", showStartStopConflicts);
     499   timelineMenuSpeakerConflict.addEventListener("click", showSpeakerNameConflicts);
     500
     501   savePopupCancel.addEventListener("click", toggleSavePopup)
     502   savePopupCommit.addEventListener("click", commitChanges);
     503   savePopupBG.addEventListener("click", toggleSavePopup);
     504   versionSelectLabels.forEach(arrow => arrow.addEventListener('click', toggleVersionDropdown));
     505
     506   volumeSlider.addEventListener("input", function() {
     507      wavesurfer.setVolume(this.value);
     508      if (this.value == 0) {
     509         muteButton.src = interface_bootstrap_images + "mute.svg";
     510         muteButton.style.opacity = 0.6;
     511      } else {
     512         muteButton.src = interface_bootstrap_images + "unmute.svg";
     513         muteButton.style.opacity = 1;
     514      }
     515   });
     516
     517   zoomSlider.addEventListener('input', function() { // slider changes waveform zoom
     518      wavesurfer.zoom(Number(this.value) / 4);
     519      if (currentRegion.speaker && getCurrentRegionIndex() != -1) {
     520         setHoverSpeaker(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element.style.left, currentRegion.speaker);
     521         drawCurrentRegionBounds();
     522      }
     523      let handles = document.getElementsByClassName("wavesurfer-handle");
     524      if (this.value < 20) {
     525         for (const handle of handles) handle.style.setProperty("width", "1px", "important");
     526      } else {
     527         for (const handle of handles) handle.style.setProperty("width", "3px", "important");
     528      }
     529   });
     530   showAudioLoader();
     531   
    661532   if (gs.variables.allowEditing === '0') { editButton.style.display = "none" }
    662533
    663    function chapterSearchInputChange(e) {
     534   wavesurfer.load(audio);
     535
     536   // wavesurfer events
     537
     538   wavesurfer.on('region-click', handleRegionClick);
     539   wavesurfer.on('region-mouseenter', function(region) { // region hover effects
     540      if (!mouseDown) {
     541         handleRegionColours(region, true);
     542         setHoverSpeaker(region.element.style.left, region.attributes.label.innerText);
     543         if (!isInCurrentRegions(region)) {
     544            removeRegionBounds();
     545            drawRegionBounds(region, wave.scrollLeft, "black");
     546         }
     547         if (isCurrentRegion(region) && editMode) drawRegionBounds(region, wave.scrollLeft, "FireBrick");
     548      }
     549   });
     550   wavesurfer.on('region-mouseleave', function(region) {
     551      hoverSpeaker.innerHTML = "";
     552      if (!mouseDown) {
     553         if (!(wavesurfer.getCurrentTime() <= region.end && wavesurfer.getCurrentTime() >= region.start)) handleRegionColours(region, false);
     554         if (!editMode) hoverSpeaker.innerHTML = "";
     555         removeRegionBounds();
     556         if (currentRegion && currentRegion.speaker && getCurrentRegionIndex() != -1) {
     557            setHoverSpeaker(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element.style.left, currentRegion.speaker);
     558            drawCurrentRegionBounds();
     559         }
     560      }
     561   });
     562   wavesurfer.on('region-in', function(region) { // play caret enters region
     563      if (!mouseDown) {
     564         handleRegionColours(region, true);
     565         if (itemType == "chapter" && Array.from(chapters.children)[getIndexOfRegion(region)]) {
     566            Array.from(chapters.children)[getIndexOfRegion(region)].scrollIntoView({ 
     567               behavior: "smooth",
     568               block: "nearest"
     569            });
     570         }
     571      }
     572   });
     573   wavesurfer.on('region-out', function(region) { handleRegionColours(region, false) });
     574   wavesurfer.on('region-update-end', handleRegionEdit); // end of click-drag event
     575   wavesurfer.on('region-updated', handleRegionSnap);
     576   wavesurfer.on('error', error => console.log(error));
     577
     578   wavesurfer.on("play", () => { playPauseButton.src = interface_bootstrap_images + "pause.svg"; });
     579   wavesurfer.on("pause", () => { playPauseButton.src = interface_bootstrap_images + "play.svg"; });
     580   wavesurfer.on("mute", function(mute) {
     581      if (mute) {
     582         muteButton.src = interface_bootstrap_images + "mute.svg";
     583         muteButton.style.opacity = 0.6;
     584         volumeSlider.value = 0;
     585      }
     586      else {
     587         muteButton.src = interface_bootstrap_images + "unmute.svg";
     588         muteButton.style.opacity = 1;
     589         volumeSlider.value = 1;
     590      }
     591   });
     592
     593   wavesurfer.on('ready', function() { // retrieve regions once waveforms have loaded
     594      window.onbeforeunload = (e) => {
     595         if (undoStates.length > 1) {
     596            console.log('undoStates.length: ' + undoStates.length);
     597            e.returnValue = "Data will be lost if you leave the page, are you sure?";
     598            return "Data will be lost if you leave the page, are you sure?";
     599         }
     600      };
     601      if (document.getElementById('new-canvas')) document.getElementById('new-canvas').remove();
     602      setTimeout(() => { // if not delayed exportImage does not retrieve waveform (despite being in waveform-ready?)
     603         const currVersion = selectedVersions[(!dualMode || primaryCaret.src.includes("fill")) ? 0 : 1];
     604         for (let key in canvasImages) {
     605            if (currVersion == key && canvasImages[key] == undefined) { canvasImages[key] = wavesurfer.exportImage() } // add waveform image to cache if one isn't already assigned to the version
     606         }
     607      }, 1000);
     608
     609      if (initialLoad) {
     610         if (inputFile.endsWith("csv")) { // diarization if csv
     611            itemType = "chapter";
     612            if (localStorage.getItem('undoStates') && localStorage.getItem('undoLevel')) {
     613               console.log('-- Loading regions from localStorage --');
     614               undoStates = JSON.parse(localStorage.getItem('undoStates'));
     615               undoLevel = JSON.parse(localStorage.getItem('undoLevel'));
     616               primarySet.tempSpeakerObjects = undoStates[undoLevel].state;
     617               primarySet.speakerObjects = cloneSpeakerObjectArray(primarySet.tempSpeakerObjects);
     618               primarySet.uniqueSpeakers = [];
     619               for (const item of primarySet.tempSpeakerObjects) {
     620                  if (!primarySet.uniqueSpeakers.includes(item.speaker)) primarySet.uniqueSpeakers.push(item.speaker);
     621               }
     622               populateChaptersAndRegions(primarySet);
     623               if (undoStates[undoLevel].secState && undoStates[undoLevel].secState.length > 0) {
     624                  secondarySet.tempSpeakerObjects = undoStates[undoLevel].secState;
     625                  secondarySet.speakerObjects = cloneSpeakerObjectArray(secondarySet.tempSpeakerObjects);
     626                  secondarySet.uniqueSpeakers = [];
     627                  for (const item of secondarySet.tempSpeakerObjects) {
     628                     if (!secondarySet.uniqueSpeakers.includes(item.speaker)) secondarySet.uniqueSpeakers.push(item.speaker);
     629                  }
     630                  secondaryLoaded = true;
     631               }
     632               updateRegionEditPanel();
     633            } else {
     634               loadCSVFile(inputFile, primarySet);
     635               dualModeChanged(true, "true");
     636               setTimeout(()=>{
     637                  dualModeChanged(true, "false");
     638               }, 150)
     639            }
     640         } else if (inputFile.endsWith("json")) { // transcription if json
     641            itemType = "word";
     642            loadJSONFile(inputFile);
     643         } else {                     
     644            console.log("Filetype of " + inputFile + " not supported.")
     645         }
     646         
     647         chapters.style.cursor = "default"; // remove load cursor
     648         wave.className = "audio-scroll";
     649         $.ajax({
     650            type: "GET",
     651            url: gs.variables.metadataServerURL,
     652            data: { a: 'get-fldv-info', site: gs.xsltParams.site_name, c: gs.cgiParams.c, d: gs.cgiParams.d },
     653            dataType: "json",
     654         }).then(data => {
     655            for (const version of ["current", ...data]) {
     656               canvasImages[version] = undefined;
     657               let menuItem = document.createElement("div");
     658               menuItem.classList.add("version-select-menu-item");
     659               menuItem.id = version;
     660               let text = version.includes("nminus") ? version.replace("nminus-", "Previous(") + ")" : version;
     661               menuItem.innerText = text.charAt(0).toUpperCase() + text.slice(1);
     662               menuItem.addEventListener('click', versionClicked);
     663               let dataObj = { a: 'get-archives-metadata', site: gs.xsltParams.site_name, c: gs.cgiParams.c, d: gs.cgiParams.d, metaname: "commitmessage" };
     664               if (version != "current") Object.assign(dataObj, {dv: version});
     665               $.ajax({ // get commitmessage metadata to show as hover tooltip
     666                  type: "GET",
     667                  url: gs.variables.metadataServerURL,
     668                  data: dataObj,
     669                  dataType: "text",
     670               }).then(comment => {
     671                  menuItem.title = "Commit message: " + comment;
     672                  versionSelectMenu.append(menuItem);
     673                  [...versionSelectMenu.children].sort((a,b) => a.innerText>b.innerText?1:-1).forEach(n=>versionSelectMenu.appendChild(n)); // sort alphabetically
     674               }, (error) => { console.log("get-archives-metadata error:"); console.log(error); });
     675            }
     676         }, (error) => { console.log("get-fldv-info error:"); console.log(error); });
     677         initialLoad = false;
     678      }
     679      // fixes blank waveform/regions when loading Current -> Prev.1 -> Prev.2
     680      zoomSlider.value = 25;
     681      zoomSlider.dispatchEvent(new Event("input"));
     682      wavesurfer.zoom(50 / 4);
     683      hideAudioLoader();
     684   });
     685
     686   /**
     687   * Draws string above waveform at the provided offset
     688   * @param {number} offset Offset (from left) to desired location
     689   * @param {string} name String to be drawn
     690   */
     691   function setHoverSpeaker(offset, name) {
     692      hoverSpeaker.innerHTML = name; 
     693      let newOffset = parseInt(offset.slice(0, -2)) - wave.scrollLeft;
     694      hoverSpeaker.style.marginLeft = newOffset + "px";
     695   }
     696
     697   /** Click handler, manages selected region/s, set swapping, region playing */
     698   function handleRegionClick(region, e) {
     699      if (e.target.classList.contains("region-menu")) return;
     700      e.stopPropagation();
     701      contextMenu.classList.remove('visible');
     702      if (!editMode) { // play region audio on click
     703         wavesurfer.play(region.start); // plays from start of region
     704      } else { // select or deselect current region
     705         if (!region.element) return;
     706         if (region.element.classList.contains("region-top")) {
     707            currSpeakerSet = primarySet;
     708            swapCarets(true);
     709         } else if (region.element.classList.contains("region-bottom")) {
     710            currSpeakerSet = secondarySet;
     711            swapCarets(false);
     712         }
     713         prevUndoState = "";
     714
     715         if (!e.ctrlKey && !e.shiftKey) {
     716            currentRegions = [];
     717            currentRegion = region;
     718            currentRegion.speaker = currentRegion.attributes.label.innerText;
     719            wavesurfer.backend.seekTo(currentRegion.start);
     720         } else if (e.ctrlKey) { // control was held during click
     721            if (currentRegions.length == 0 && isCurrentRegion(region)) {
     722               removeCurrentRegion();
     723            } else if (getCurrentRegionIndex() != -1 && isInCurrentRegions(region)) {
     724               const removeIndex = getIndexInCurrentRegions(region);
     725               if (removeIndex != -1) currentRegions.splice(removeIndex, 1);
     726               if (currentRegions.length > 0 && isCurrentRegion(region)) { // change current region if removed
     727                  currentRegion = currentRegions[0];
     728               }
     729            } else {
     730               if (currentRegions.length < 1) currentRegions.push(currentRegion);
     731               if (getIndexInCurrentRegions(region) == -1) currentRegions.push(region); // add if it doesn't already exist
     732               currentRegion = region;
     733               currentRegion.speaker = currentRegion.attributes.label.innerText;
     734               wavesurfer.backend.seekTo(currentRegion.start);
     735            }
     736            if (currentRegions.length == 1)  currentRegions = []; // clear selected regions if there is only one
     737         } else if (e.shiftKey) { // shift was held during click
     738            clearChapterSearch();
     739            if (getCurrentRegionIndex() != -1 && getIndexOfRegion(region) != -1) {
     740               if (currentRegions && currentRegions.length > 0) {
     741                  if (Math.max(...getCurrentRegionsIndexes()) < getIndexOfRegion(region)) { // shifting forwards / down
     742                     currentRegions = currSpeakerSet.tempSpeakerObjects.slice(Math.min(...getCurrentRegionsIndexes()), getIndexOfRegion(region)+1);
     743                  } else { // shifting backwards / up
     744                     currentRegions = currSpeakerSet.tempSpeakerObjects.slice(getIndexOfRegion(region), Math.max(...getCurrentRegionsIndexes())+1);
     745                  }
     746               } else {
     747                  if (getCurrentRegionIndex() < getIndexOfRegion(region)) { // shifting forwards / down
     748                     currentRegions = currSpeakerSet.tempSpeakerObjects.slice(getCurrentRegionIndex(), getIndexOfRegion(region)+1);
     749                  } else { // shifting backwards / up
     750                     currentRegions = currSpeakerSet.tempSpeakerObjects.slice(getIndexOfRegion(region), getCurrentRegionIndex()+1);
     751                  }
     752               }
     753            }
     754         }
     755         if (changeAllCheckbox.checked) { currentRegions = getRegionsWithSpeaker(currentRegion.speaker) }
     756         reloadRegionsAndChapters();
     757      }
     758   }
     759
     760   /**
     761   * Returns index of given region within the currently selected regions
     762   * @param {object} region Region within currently selected regions to return index for
     763   * @returns {int} Index position of region
     764   */
     765   function getIndexInCurrentRegions(region) {
     766      for (const reg of currentRegions) {
     767         const regSpeaker = reg.attributes ? reg.attributes.label.innerText : reg.speaker;
     768         if (reg.start == region.start && reg.end == region.end && regSpeaker == region.attributes.label.innerText) {
     769            return currentRegions.indexOf(reg);
     770         }
     771      }
     772      return -1;
     773   }
     774
     775   /**
     776   * Returns index of region within speakerObject array
     777   * @param {object} region Region to return index for
     778   * @returns {int} Index position of region
     779   */
     780   function getIndexOfRegion(region) {
     781      for (const reg of currSpeakerSet.tempSpeakerObjects) {
     782         if (region.attributes && reg.start == region.start && reg.end == region.end && reg.speaker == region.attributes.label.innerText) {
     783            return currSpeakerSet.tempSpeakerObjects.indexOf(reg);
     784         }
     785      }
     786      return -1;
     787   }
     788
     789   /**
     790   * Builds metadata-server.pl URL to retrieve audio at given version
     791   * @param {string} version GS document version to retrieve from (nminus-X)
     792   */
     793   function getAudioURLFromVersion(version) {
     794      let base_url = gs.variables.metadataServerURL + "?a=get-archives-assocfile&site=" + gs.xsltParams.site_name + "&c=" + gs.cgiParams.c + "&d=" + gs.cgiParams.d;
     795      if (version !== "current") base_url += "&dv=" + version // get fldv if not current version
     796      return base_url  + "&assocname=" + gs.documentMetadata.Audio;
     797   }
     798
     799   /**
     800   * Builds metadata-server.pl URL to retrieve CSV at given version
     801   * @param {string} version GS document version to retrieve from (nminus-X)
     802   */
     803   function getCSVURLFromVersion(version) {
     804      let base_url = gs.variables.metadataServerURL + "?a=get-archives-assocfile&site=" + gs.xsltParams.site_name + "&c=" + gs.cgiParams.c + "&d=" + gs.cgiParams.d;
     805      if (version !== "current") base_url += "&dv=" + version; // get fldv if not current version
     806      return base_url  + "&assocname=" + "structured-audio.csv";
     807   }
     808
     809   /** Version click handler, first checks if changes have been made and shows popup if true */
     810   function versionClicked(e) {
     811      let unsavedChanges = false;
     812      if (undoStates.length > 0) { // only if changes have been made in track being changed FROM
     813         let clickedVersionPos = e.target.parentElement.classList.contains('versionTop') ? 0 : 1;
     814         for (const state of undoStates) {
     815            if (state.changedTrack == selectedVersions[clickedVersionPos]) {
     816               unsavedChanges = true;
     817               break;
     818            }
     819         }
     820      }
     821      if (unsavedChanges) {
     822         const areYouSure = "There are unsaved changes.\nAre you sure you want to lose changes made in this version?";
     823         if (window.confirm(areYouSure)) {
     824            console.log('OK');
     825            discardRegionChanges(true);
     826            changeVersion(e);
     827         } else {
     828            console.log('CANCEL');
     829            return;
     830         }
     831      } else changeVersion(e);
     832   }
     833
     834   /** Changes current audio/csv set to clicked version's equivalent */
     835   function changeVersion(e) { 
     836      removeCurrentRegion();
     837      const audio_url = getAudioURLFromVersion(e.target.id);
     838      const csv_url = getCSVURLFromVersion(e.target.id);
     839      versionSelectMenu.classList.remove('visible');
     840      const setToUpdate = e.target.parentElement.classList.contains('versionTop') ? primarySet : secondarySet;
     841      if (e.target.parentElement.classList.contains('versionTop')) {
     842         if (!currSpeakerSet.isSecondary) {
     843            if (dualMode) $(".region-top").remove();
     844            else $(".wavesurfer-region").remove();
     845            showAudioLoader();
     846            // if (canvasImages[e.target.id]) { // if waveform image exists in cache
     847            //    drawImageOnWaveform(canvasImages[e.target.id]);
     848            // }           
     849            wavesurfer.load(audio_url); // load audio
     850         } else {
     851            $(".region-top").remove();
     852         }
     853         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
     854         selectedVersions[0] = e.target.id; // update the selected versions
     855      } else {
     856         if (currSpeakerSet.isSecondary) {
     857            if (dualMode) $(".region-bottom").remove();
     858            else $(".wavesurfer-region").remove();
     859            showAudioLoader();
     860            // if (canvasImages[e.target.id]) { // if waveform image exists in cache
     861            //    drawImageOnWaveform(canvasImages[e.target.id]);
     862            // }
     863            wavesurfer.load(audio_url);
     864         } else {
     865            $(".region-bottom").remove();
     866         }
     867         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
     868         selectedVersions[1] = e.target.id;
     869      }
     870      loadCSVFile(csv_url, setToUpdate, true);
     871   }
     872   
     873   /** Utility function to download audio */
     874   function downloadURI(loc, name) {
     875      let link = document.createElement("a");
     876      link.download = name;
     877      link.href = loc;
     878      link.click();
     879   }
     880
     881   /** Document click listener for context box closure and region deselection */
     882   function documentClicked(e) { // document on click
     883      if (e.target.classList.contains("region-menu")) return;
     884      contextMenu.classList.remove('visible');
     885      timelineMenu.classList.remove('visible');
     886      versionSelectMenu.classList.remove('visible');
     887      versionSelectLabels.forEach(arrow => {
     888         // arrow.style.transform = 'rotate(90deg)';
     889         // arrow.style.paddingTop = '0';
     890         arrow.style.display = 'inline';
     891      });
     892      if (editMode && e.target.tagName !== "INPUT" && e.target.tagName !== "IMG" && !e.target.classList.contains("ui-button") && !$("#audio-dropdowns").has($(e.target)).length
     893         && !e.target.classList.contains("context-menu-item")) {
     894         let currReg = getCurrentRegionIndex() != -1 ? currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region : false; // save for deselection
     895         let currRegs = getCurrentRegionsIndexes().length > 1 ? currentRegions : false; // save for deselection
     896         removeCurrentRegion();
     897         reloadChapterList();
     898         if (currReg != false) regionLeave(currReg); // deselect curr region
     899         if (currRegs != false) {
     900            for (const reg of currRegs) {
     901               regionLeave(reg.region); // deselect curr regions
     902               regionLeave(reg.region); // deselect curr regions
     903            }
     904         }
     905         removeRegionBounds();
     906         removeButton.innerHTML = "Remove Selected Region";
     907         updateRegionEditPanel();
     908      }
     909   }
     910
     911   /** Draws and returns padlock image at given parent element */
     912   function drawPadlock(parent) {
     913      let lockedImg = document.createElement("img");
     914      lockedImg.classList.add("region-padlock");
     915      lockedImg.src = interface_bootstrap_images + "lock.svg";
     916      lockedImg.title = "This region is locked. Click to unlock region.";
     917      parent.prepend(lockedImg);
     918      return lockedImg;
     919   }
     920
     921   /**
     922    * Draws triple dot menu button and attaches click listener
     923    * @param {object} region Region to attach menu button to
     924    */
     925   function drawMenuButton(region) {
     926      let menuImg = document.createElement("img");
     927      menuImg.src = interface_bootstrap_images + "menu.svg";
     928      menuImg.classList.add("region-menu");
     929      menuImg.title = "Show region options";
     930      menuImg.addEventListener("click", e => {
     931         audioContainer.dispatchEvent(new MouseEvent("contextmenu", { clientX: menuImg.x + 20, clientY: menuImg.y + 5 }));
     932      });
     933      region.element.append(menuImg);
     934   }
     935
     936   /**
     937   * Attaches a click listener to given padlock element
     938   * @param padlock Element to attach listener to
     939   * @param region Associated region
     940   * @param isChapter Whether padlock exists in chapter (true) or wavesurfer region (false)
     941   */
     942   function attachPadlockListener(padlock, region, isChapter) {
     943      if (isChapter == true) {
     944         padlock.addEventListener('click', () => { // attach to chapter padlock
     945            let index = getIndexOfRegion(region);
     946            currSpeakerSet.tempSpeakerObjects[index].locked = false;
     947            padlock.classList.add('hide');
     948            if (currSpeakerSet.tempSpeakerObjects[index].region.element.firstChild) currSpeakerSet.tempSpeakerObjects[index].region.element.firstChild.remove();
     949            addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "lockChange", index);
     950         });
     951      } else {
     952         padlock.addEventListener('click', () => { // attach to region padlock
     953            let index = getIndexOfRegion(region);
     954            currSpeakerSet.tempSpeakerObjects[index].locked = false;
     955            padlock.remove();
     956            addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "lockChange", index);
     957         });
     958      }
     959   }
     960
     961   /** Locks or unlocks selected region based on its current state */
     962   function toggleLockSelected(e) { // locks / unlocks selected region(s)
     963      if (e) e.stopPropagation();
     964      if (getCurrentRegionIndex() != -1 && currentRegions.length <= 1) { // single selected
     965         let currIndex = getCurrentRegionIndex();
     966         currSpeakerSet.tempSpeakerObjects[currIndex].locked = !e.target.innerText.includes("Unlock");
     967         if (currSpeakerSet.tempSpeakerObjects[currIndex].locked) {
     968            chapters.childNodes[currIndex].childNodes[1].classList.remove('hide');
     969            let lock = drawPadlock(currSpeakerSet.tempSpeakerObjects[currIndex].region.element);
     970            attachPadlockListener(lock, currSpeakerSet.tempSpeakerObjects[currIndex].region, false);
     971            contextLock.innerText = "Unlock Selected";
     972         } else {
     973            chapters.childNodes[currIndex].childNodes[1].classList.add('hide');
     974            if (currSpeakerSet.tempSpeakerObjects[currIndex].region.element.getElementsByClassName("region-padlock").length > 0) {
     975               currSpeakerSet.tempSpeakerObjects[currIndex].region.element.getElementsByClassName("region-padlock")[0].remove();
     976            }
     977            contextLock.innerText = "Lock Selected";
     978         }
     979      } else if (currentRegions.length > 1) { // multiple selected
     980         let toLock = !e.target.innerText.includes("Unlock");
     981         for (const idx of getCurrentRegionsIndexes()) {
     982            currSpeakerSet.tempSpeakerObjects[idx].locked = toLock;
     983            if (currSpeakerSet.tempSpeakerObjects[idx].locked) {
     984               chapters.childNodes[idx].childNodes[1].classList.remove('hide');
     985               if (currSpeakerSet.tempSpeakerObjects[idx].region.element.getElementsByClassName("region-padlock").length == 0) {
     986                  let lock = drawPadlock(currSpeakerSet.tempSpeakerObjects[idx].region.element);
     987                  attachPadlockListener(lock, currSpeakerSet.tempSpeakerObjects[idx].region, false);
     988               }
     989               contextLock.innerText = "Unlock Selected";
     990            } else {
     991               chapters.childNodes[idx].childNodes[1].classList.add('hide');
     992               if (currSpeakerSet.tempSpeakerObjects[idx].region.element.getElementsByClassName("region-padlock").length > 0) {
     993                  currSpeakerSet.tempSpeakerObjects[idx].region.element.getElementsByClassName("region-padlock")[0].remove();
     994               }
     995               contextLock.innerText = "Lock Selected";
     996            }
     997         }
     998         if (document.getElementById("context-menu-lock-2")) document.getElementById("context-menu-lock-2").remove();
     999      }
     1000      addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "lockChange", getCurrentRegionIndex());
     1001   }
     1002
     1003   /** TODO */
     1004   function timelineMenuHideClicked(e) { // hides all regions and chapter/edit divs
     1005      if (!e.target.children[0].checked) {
     1006         e.target.children[0].checked = true;
     1007         timelineMenuDualMode.classList.add('disabled');
     1008         timelineMenuRegionConflict.classList.add('disabled');
     1009         timelineMenuSpeakerConflict.classList.add('disabled');
     1010         if (editPanel.style.height != "0px") toggleEditMode();
     1011         if (chapters.style.height != "0px") toggleChapters();
     1012         $('.wavesurfer-region').fadeOut(100);
     1013      }
     1014      else {
     1015         e.target.children[0].checked = false;
     1016         timelineMenuDualMode.classList.remove('disabled');
     1017         timelineMenuRegionConflict.classList.remove('disabled');
     1018         timelineMenuSpeakerConflict.classList.remove('disabled');
     1019         let fadeIn = true;
     1020         if (timelineMenuRegionConflict.firstElementChild.checked) {
     1021            showStartStopConflicts(e, true);
     1022            fadeIn = false;
     1023         }
     1024         if (timelineMenuSpeakerConflict.firstElementChild.checked) {
     1025            showSpeakerNameConflicts(e, true);
     1026            fadeIn = false;
     1027         }
     1028         if (fadeIn) $('.wavesurfer-region').fadeIn(100);
     1029      }
     1030   }
     1031
     1032   function chapterSearchInputChange(e) { // filters chapters and regions by given speaker name
    6641033      if (e.isTrusted) { // triggered from user action
    6651034         if (document.getElementById("chapter-alert")) document.getElementById("chapter-alert").remove();
     
    6951064   }
    6961065
    697    function clearChapterSearch() {
     1066   function clearChapterSearch() { // clears search filter and updates results
    6981067      chapterSearchInput.value = "";
    6991068      chapterSearchInput.dispatchEvent(new Event("input"));
    7001069   }
    7011070
    702    function dualModeMenuToggle() {
    703       if (editMode && dualMode) {
    704          if (dualModeMenu.classList.contains('visible')) dualModeMenu.classList.remove('visible');
    705          else dualModeMenu.classList.add('visible');
    706       }
    707    }
    708 
    709    function handleRegionSnap(region, e) {
    710       if (editMode && currentRegion) {
     1071   function showStartStopConflicts(e, forceRun) { // hides regions that have identical start/stop time
     1072      removeCurrentRegion();
     1073      if ((dualMode && !timelineMenuRegionConflict.children[0].checked) || forceRun) {
     1074         timelineMenuRegionConflict.children[0].checked = true;
     1075         let primHide = [];
     1076         let secHide = [];
     1077         if (!timelineMenuSpeakerConflict.children[0].checked) hideAll();
     1078         for (const primIdx in primarySet.tempSpeakerObjects) {
     1079            for (const secIdx in secondarySet.tempSpeakerObjects) {
     1080               if (regionsMatch(primarySet.tempSpeakerObjects[primIdx], secondarySet.tempSpeakerObjects[secIdx])) { // if regions have same start/end time, hide
     1081                  primHide.push(primIdx);
     1082                  secHide.push(secIdx);
     1083               }
     1084            }
     1085         }
     1086         for (const primIdx in primarySet.tempSpeakerObjects) {
     1087            if (!primHide.includes(primIdx)) {
     1088               primarySet.tempSpeakerObjects[primIdx].region.element.style.display = "";
     1089               if (primaryCaret.src.includes('fill')) chapters.children[primIdx].style.display = "flex";
     1090            }
     1091         }
     1092         for (const secIdx in secondarySet.tempSpeakerObjects) {
     1093            if (!secHide.includes(secIdx)) {
     1094               secondarySet.tempSpeakerObjects[secIdx].region.element.style.display = "";
     1095               if (secondaryCaret.src.includes('fill')) chapters.children[secIdx].style.display = "flex";
     1096            }
     1097         }
     1098      } else {
     1099         timelineMenuRegionConflict.children[0].checked = false;
     1100         if (timelineMenuSpeakerConflict.children[0].checked) showSpeakerNameConflicts(e, true);
     1101         else clearConflicts();
     1102      }
     1103   }
     1104   
     1105   function showSpeakerNameConflicts(e, forceRun) { // shows regions that have identical start/stop time but different names
     1106      removeCurrentRegion();
     1107      if ((dualMode && !timelineMenuSpeakerConflict.children[0].checked) || forceRun) {
     1108         timelineMenuSpeakerConflict.children[0].checked = true;
     1109         if (!timelineMenuRegionConflict.children[0].checked) hideAll();
     1110         for (const primIdx in primarySet.tempSpeakerObjects) {
     1111            for (const secIdx in secondarySet.tempSpeakerObjects) {
     1112               if (regionsMatch(primarySet.tempSpeakerObjects[primIdx], secondarySet.tempSpeakerObjects[secIdx]) &&
     1113               primarySet.tempSpeakerObjects[primIdx].speaker != secondarySet.tempSpeakerObjects[secIdx].speaker) { // hide if regions match but names don't
     1114                  primarySet.tempSpeakerObjects[primIdx].region.element.style.display = "";
     1115                  secondarySet.tempSpeakerObjects[secIdx].region.element.style.display = "";
     1116                  if (primaryCaret.src.includes('fill')) chapters.children[primIdx].style.display = "flex";
     1117                  else chapters.children[secIdx].style.display = "flex";
     1118               }
     1119            }
     1120         }
     1121      } else {
     1122         timelineMenuSpeakerConflict.children[0].checked = false;
     1123         if (timelineMenuRegionConflict.children[0].checked) showStartStopConflicts(e, true);
     1124         else clearConflicts();
     1125      }
     1126   }
     1127
     1128   function clearConflicts() { // shows all regions and chapters
     1129      for (const primIdx in primarySet.tempSpeakerObjects) {
     1130         for (const secIdx in secondarySet.tempSpeakerObjects) {
     1131            primarySet.tempSpeakerObjects[primIdx].region.element.style.display = "";
     1132            secondarySet.tempSpeakerObjects[secIdx].region.element.style.display = "";
     1133            chapters.children[primIdx].style.display = "flex";
     1134         }
     1135      }
     1136   }
     1137
     1138   function hideAll() { // hides all regions and chapters
     1139      for (const primIdx in primarySet.tempSpeakerObjects) {
     1140         for (const secIdx in secondarySet.tempSpeakerObjects) {
     1141            primarySet.tempSpeakerObjects[primIdx].region.element.style.display = "none";
     1142            secondarySet.tempSpeakerObjects[secIdx].region.element.style.display = "none";
     1143            chapters.children[primIdx].style.display = "none";
     1144         }
     1145      }
     1146   }
     1147
     1148   function timelineMenuToggle(e) { // shows / hides timeline menu
     1149      e.stopPropagation();
     1150      if (timelineMenu.classList.contains('visible')) {
     1151         timelineMenu.classList.remove('visible');
     1152         e.target.style.transform = 'rotate(0deg)';
     1153      }
     1154      else {
     1155         timelineMenu.classList.add('visible');
     1156         e.target.style.transform = 'rotate(-90deg)';
     1157      }
     1158   }
     1159
     1160   function handleRegionSnap(region, e) { // clips region to opposite set region if nearby, called on region update (lots)
     1161      if (editMode && currentRegion && !wavesurfer.isPlaying()) {
    7111162         removeRegionBounds();
    7121163         setHoverSpeaker(region.element.style.left, currentRegion.speaker);
    713          drawRegionBounds(region, wave.scrollLeft, "FireBrick");
     1164         drawRegionBounds(region, wave.scrollLeft, "FireBrick"); // gets set to red if currRegion
    7141165         if (e && e.action === "resize" && dualMode && editMode && !ctrlDown) { // won't actuate on drag
    7151166            let oppositeSet = secondarySet; // look down
     
    7281179   }
    7291180
     1181   /**
     1182   * Returns snap value if near [snapRadius] adjacent region edge
     1183   * @param newDragPos Drag position in seconds to check for
     1184   * @param speakerSet Adjacent region set
     1185   * @returns {number} If found, returns snapped position, otherwise returns input position
     1186   */
    7301187   function getSnapValue(newDragPos, speakerSet) {
    7311188      const snapRadius = 1;     
    7321189      for (const region of speakerSet) { // scan opposite region for potential snapping points
    7331190         if (newDragPos > parseFloat(region.start) - snapRadius && newDragPos < parseFloat(region.start) + snapRadius) {
    734             // console.log("snap to start: " + region.start);
    7351191            snappedTo = "start";
    7361192            if (snappedToX == 0) snappedToX = waveformCursorX;
     
    7381194         }
    7391195         if (newDragPos > parseFloat(region.end) - snapRadius && newDragPos < parseFloat(region.end) + snapRadius) {
    740             // console.log("snap to end: " + region.end);
    7411196            snappedTo = "end";
    7421197            if (snappedToX == 0) snappedToX = waveformCursorX;
    7431198            return region.end;
    7441199         }
    745 
    7461200         if (snappedTo !== "none" && (waveformCursorX - snappedToX > 10 || waveformCursorX - snappedToX < -10)) {
    747             // console.log('released!');
    7481201            snappedTo = "none";
    7491202            snappedToX = 0;
     
    7651218   }
    7661219
    767    function removeRightClicked(e) {
    768       if (!e.target.classList.contains('faded')) {
     1220   function removeRightClicked(e) { 
     1221      if (!e.target.classList.contains('disabled')) {
    7691222         removeRegion();
    7701223      }
    7711224   }
    7721225
    773    function replaceSelected(e) {
    774       if (!e.target.classList.contains('faded')) {
     1226   function replaceSelected(e) { // moves selected region across, replaces and removes any overlapping regions in the opposite set
     1227      if (!e.target.classList.contains('disabled')) {
    7751228         let destinationSet = secondarySet; // replace down
    7761229         if (currSpeakerSet.isSecondary) destinationSet = primarySet; // replace up
     
    7931246   }
    7941247
    795    function containsRegion(set, region) {
     1248   function containsRegion(set, region) { // true if given region exists in given set
    7961249      for (const item of set) {
    7971250         if (regionsMatch(region, item)) return true;
     
    8001253   }
    8011254
    802    function overdubSelected(e) {
    803       if (!e.target.classList.contains('faded')) {
     1255   function overdubSelected(e) { // moves selected region across, merges any overlapping regions in the opposite set
     1256      if (!e.target.classList.contains('disabled')) {
    8041257         let destinationSet = secondarySet; // replace down
    8051258         if (currSpeakerSet.isSecondary) destinationSet = primarySet; // replace up
    8061259         let backup;
    807          if (destinationSet.isSecondary) backup = cloneSpeakerObjectArray(primarySet.tempSpeakerObjects); // saves selected set as this process changes values in selected set (unknown reason)
     1260         if (destinationSet.isSecondary) backup = cloneSpeakerObjectArray(primarySet.tempSpeakerObjects); // saves selected set as this process changes values in selected set
    8081261         else backup = cloneSpeakerObjectArray(secondarySet.tempSpeakerObjects);
    809             copySelected(e, true);
     1262         copySelected(e, true);
    8101263         if (!currentRegions || currentRegions.length < 1) { // overdub single
    811             handleSameSpeakerOverlap(getCurrentRegionIndex(), destinationSet);
     1264            handleSameSpeakerOverlap(getCurrentRegionIndex(), destinationSet, true);
    8121265         } else { // overdub multiple
    8131266            for (const item of getCurrentRegionsIndexes().reverse()) { // reverse indexes so index doesn't break when regions are removed
    814                handleSameSpeakerOverlap(item, destinationSet);
     1267               handleSameSpeakerOverlap(item, destinationSet, true);
    8151268            }
    8161269         }
     
    8221275   }
    8231276
    824    function copySelected(e, skipUndoState) {
    825       if (!e.target.classList.contains('faded')) {
    826          let out = -1;
     1277   function copySelected(e, skipUndoState) { // copies region to opposite set [utility function for replace and overdub]
     1278      if (!e.target.classList.contains('disabled')) {
    8271279         let destinationSet = secondarySet; // copy down
    828          if (currSpeakerSet.isSecondary) { destinationSet = primarySet } // copy up
     1280         if (currSpeakerSet.isSecondary) destinationSet = primarySet // copy up
     1281            const selectedRegion = currentRegion;
    8291282         if (currentRegions && currentRegions.length > 1) { // copy multiple
    830             const selectedRegion = currentRegion;
    831             const selectedRegions = currentRegions;
    832             destinationSet.tempSpeakerObjects.push(...selectedRegions);
    833             currSpeakerSet.isSecondary ? caretClicked("primary-caret") : caretClicked("secondary-caret"); // swap selected speakerSet (clears current regions)
    834             for (const reg of destinationSet.tempSpeakerObjects) { // restore currentRegions in dest. set
    835                for (const selReg of selectedRegions) {
    836                   if (regionsMatch(reg, selReg) && !containsRegion(currentRegions, reg)) {
    837                      currentRegions.push(reg);
    838                   }
    839                }
    840                if (regionsMatch(reg, selectedRegion)) { currentRegion = reg; }
    841             }
     1283            destinationSet.tempSpeakerObjects.push(...selectedRegions); // append current regions to dest. set
     1284            // currSpeakerSet.isSecondary ? caretClicked("primary-caret") : caretClicked("secondary-caret"); // swap selected speakerSet (clears current regions)
     1285            // for (const reg of destinationSet.tempSpeakerObjects) { // restore currentRegions in dest. set
     1286            //    for (const selReg of selectedRegions) {
     1287            //       if (regionsMatch(reg, selReg) && !containsRegion(currentRegions, reg)) {
     1288            //          currentRegions.push(reg);
     1289            //       }
     1290            //    }
     1291            //    if (regionsMatch(reg, selectedRegion)) { currentRegion = reg; }
     1292            // }
    8421293         } else { // copy singular
    843             const selectedRegion = currentRegion; // copy currRegion as caretClicked wipes it
    8441294            destinationSet.tempSpeakerObjects.push(selectedRegion); // append current region to dest. set
    845             currSpeakerSet.isSecondary ? caretClicked("primary-caret") : caretClicked("secondary-caret"); // swap selected speakerSet (clears current regions)
    846             for (const reg of destinationSet.tempSpeakerObjects) { // restore currentRegion in dest. set
    847                if (regionsMatch(reg, selectedRegion)) {
    848                   currentRegion = reg;
    849                   break;
    850                }
    851             }
     1295            // currSpeakerSet.isSecondary ? caretClicked("primary-caret") : caretClicked("secondary-caret"); // swap selected speakerSet (clears current regions)
     1296            // for (const reg of destinationSet.tempSpeakerObjects) { // restore currentRegion in dest. set
     1297            //    if (regionsMatch(reg, selectedRegion)) {
     1298            //       currentRegion = reg;
     1299            //       break;
     1300            //    }
     1301            // }
    8521302         }
    8531303         reloadRegionsAndChapters();
     
    8561306   }
    8571307
     1308   /**
     1309    * Shows context menu with various region options
     1310    * @param {MouseEvent} e Either right click event or left click triple menu click event
     1311    */
    8581312   function onRightClick(e) {
    859       if (e.target.classList.contains("wavesurfer-region") && editMode) {
     1313      if ((e.target.classList.contains("wavesurfer-region") || e.target.id === "audioContainer" || e.target.classList.contains("chapter")) && editMode) {
    8601314         e.preventDefault();
     1315         e.stopPropagation();
     1316         // set current region to clicked region LLLLLLL
     1317         let clickedRegion; // could be used to select clicked region
     1318         for (const reg of currSpeakerSet.tempSpeakerObjects) {
     1319            if (reg.region.element.title == e.target.title) {
     1320               clickedRegion = reg;
     1321               break;
     1322            }
     1323         }
     1324         // console.log(clickedRegion)
    8611325         contextMenu.classList.add("visible");
    8621326         if (e.clientX + 200 > $(window).width()) contextMenu.style.left = ($(window).width() - 220) + "px"; // ensure menu doesn't clip on right
     
    8641328         contextMenu.style.top = e.clientY + "px";
    8651329
     1330         let lockConflict = false;
     1331         if (currentRegions.length > 1) {
     1332            let firstIsLocked = 0;
     1333            for (const reg of currentRegions) {
     1334               if (firstIsLocked === 0) firstIsLocked = reg.locked;
     1335               else if (firstIsLocked != reg.locked) lockConflict = true;
     1336            }
     1337         }
     1338         if (lockConflict) {
     1339            contextLock.classList.remove('disabled');
     1340            if (!document.getElementById("context-menu-lock-2")) {
     1341               let contextLock2 = contextLock.cloneNode();
     1342               contextLock.innerText = "Lock Selected";
     1343               contextLock2.innerText = "Unlock Selected";
     1344               contextLock2.id = "context-menu-lock-2";
     1345               contextLock2.addEventListener('click', toggleLockSelected);
     1346               contextLock.parentNode.insertBefore(contextLock2, contextLock.nextSibling);
     1347            }
     1348         } else {
     1349            contextLock.classList.remove('disabled');
     1350            let currIndex = getCurrentRegionIndex();
     1351            if (currSpeakerSet.tempSpeakerObjects[currIndex] && currSpeakerSet.tempSpeakerObjects[currIndex].locked) {
     1352               contextLock.innerText = "Unlock Selected";
     1353               chapters.childNodes[currIndex].childNodes[1].classList.remove('hide'); 
     1354            } else if (currSpeakerSet.tempSpeakerObjects[currIndex]) {
     1355               contextLock.innerText = "Lock Selected";
     1356               chapters.childNodes[currIndex].childNodes[1].classList.add('hide');
     1357            }
     1358         }
     1359
    8661360         if (dualMode && currentRegion && currentRegion.speaker !== "") {
    867             contextReplace.classList.remove('faded');
    868             contextOverdub.classList.remove('faded');
    869             // contextCopy.classList.remove('faded');
     1361            contextReplace.classList.remove('disabled');
     1362            contextOverdub.classList.remove('disabled');
    8701363         } else {
    871             contextDelete.classList.add('faded');
    872             contextReplace.classList.add('faded');
    873             contextOverdub.classList.add('faded');
    874             // contextCopy.classList.add('faded');
    875          }
    876          if (currentRegion && currentRegion.speaker !== "") contextDelete.classList.remove('faded');
     1364            contextDelete.classList.add('disabled');
     1365            contextLock.classList.add('disabled');
     1366            contextReplace.classList.add('disabled');
     1367            contextOverdub.classList.add('disabled');
     1368         }
     1369         if (currentRegion && currentRegion.speaker !== "") {
     1370            contextDelete.classList.remove('disabled');
     1371            contextLock.classList.remove('disabled');
     1372         }
    8771373         if (dualMode) { // manipulate context texts
    8781374            const actionDirection = currSpeakerSet.isSecondary ? "Up" : "Down";
    8791375            contextReplace.innerHTML = "Replace Selected " + actionDirection;
    8801376            contextOverdub.innerHTML = "Overdub Selected " + actionDirection;
    881             // contextCopy.innerHTML = "Copy Selected " + actionDirection;
    8821377         }
    8831378      }
     
    8911386   }
    8921387
    893    function keyUp(e) {
     1388   function keyUp(e) { // key up listener
    8941389      if (e.key == "Control") ctrlDown = false;
    8951390      if (e.target.tagName !== "INPUT") {
    8961391         if (e.code === "Backspace" || e.code === "Delete") removeRegion();
    897          else if (e.code === "Space") wavesurfer.playPause();
     1392         else if (e.code === "Space") { wavesurfer.playPause(); }
    8981393         else if (e.code === "ArrowLeft") wavesurfer.skipBackward();
    8991394         else if (e.code === "ArrowRight") wavesurfer.skipForward();
     1395         else if (e.code === "KeyL") toggleLockSelected(e);
    9001396      }
    9011397      if (e.code == "KeyZ" && e.ctrlKey) undo();
     
    9031399   }
    9041400
    905    function keyDown(e) {
     1401   function keyDown(e) { // keydown listener
    9061402      if (e.key == "Control") ctrlDown = true;
    907    }
    908 
    909    function dualModeChanged(skipUndoState) { // on dualmode checkbox value change
    910       clearChapterSearch();
     1403      if (e.code == "Space" && e.target.tagName.toLowerCase() != "input") e.preventDefault();
     1404   }
     1405
     1406   /**
     1407   * Shows / hides secondary speaker set
     1408   * @param skipUndoState Utility param - skips the addition of an undo state
     1409   * @param overrideValue Utility param - overrides the checkbox state
     1410   */
     1411   function dualModeChanged(skipUndoState, overrideValue) {
     1412      if (overrideValue) dualModeCheckbox.checked = overrideValue == "true" ? true : false;
     1413      else dualModeCheckbox.checked = !dualModeCheckbox.checked; // toggle dual mode checkbox
    9111414      dualMode = dualModeCheckbox.checked;
    9121415      currSpeakerSet = primarySet;
    9131416      if (!dualMode) removeCurrentRegion();
     1417      clearChapterSearch();
    9141418      reloadRegionsAndChapters();
    9151419      if (dualMode) {
    916          dualModeMenuButton.classList.add('visible');
    9171420         if (!secondaryLoaded) {
    918             loadCSVFile(inputFile.replace(".csv", "-2.csv"), ["speaker", "start", "end"], secondarySet);
     1421            const secondaryCSVURL = "http://localhost:8383/greenstone3/cgi-bin/metadata-server.pl?a=get-archives-assocfile&site=" + gs.xsltParams.site_name + "&c=" + gs.collectionMetadata.indexStem +
     1422                                    "&d=" + gs.documentMetadata.Identifier + "&assocname=structured-audio.csv&dv=nminus-1";
     1423            loadCSVFile(secondaryCSVURL, secondarySet);
    9191424            secondaryLoaded = true; // ensure secondarySet doesn't get re-read > once
    9201425         }
    9211426         document.getElementById("caret-container").style.display = "flex";
     1427         timelineMenuRegionConflict.classList.remove("disabled");
     1428         timelineMenuSpeakerConflict.classList.remove("disabled");
     1429         $('#track-set-label-bottom').fadeIn(100);
     1430         selectedVersions[1] = document.getElementById('track-set-label-bottom').children[0].innerText;
    9221431      } else {
    923          dualModeMenuButton.classList.remove('visible');
    9241432         caretClicked('primary-caret');
    9251433         document.getElementById("caret-container").style.display = "none";
     1434         selectedVersions.splice(1, 1); // trim to one version in array
     1435         timelineMenuRegionConflict.firstElementChild.checked = false;
     1436         timelineMenuSpeakerConflict.firstElementChild.checked = false;
     1437         timelineMenuRegionConflict.classList.add("disabled");
     1438         timelineMenuSpeakerConflict.classList.add("disabled");
     1439         $('#track-set-label-bottom').fadeOut(100);
    9261440      }
    9271441      currSpeakerSet = primarySet;
    928       drawVersionNames();
    9291442      if (!skipUndoState) addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "dualModeChange", getCurrentRegionIndex());
    9301443   }
    9311444
    932    // path to toolbar images
    933    let interface_bootstrap_images = "interfaces/" + gs.xsltParams.interface_name + "/images/bootstrap/";
    934 
     1445   /**
     1446   * Changes selected speaker set
     1447   * @param {string} id ID of clicked caret image
     1448   */
    9351449   function caretClicked(id) {
    9361450      clearChapterSearch();
     
    9441458   }
    9451459
     1460   /**
     1461   * Loads destination waveform and audio if required, updates caret images
     1462   * @param {boolean} toPrimary whether destination set is primary (true) or secondary (false)
     1463   */
    9461464   function swapCarets(toPrimary) {
    947       const currCaretIsPrimary = primaryCaret.src.includes("fill") ? true : false;
     1465      const currCaretIsPrimary = primaryCaret.src.includes("fill") ? true : false; // initial value before swap
    9481466      if ((toPrimary && !currCaretIsPrimary) || (!toPrimary && currCaretIsPrimary)) {
    9491467         removeCurrentRegion(); // ensure currentRegion is only removed if changing speakerSet
     
    9521470      }
    9531471      if (toPrimary) {
     1472         if (!currCaretIsPrimary) {
     1473            showAudioLoader();
     1474            if (canvasImages[selectedVersions[0]]) { // if waveform image exists in cache
     1475               drawImageOnWaveform(canvasImages[selectedVersions[0]]);
     1476               // hideAudioLoader();
     1477            }
     1478            // else showAudioLoader();
     1479            let url = gs.variables.metadataServerURL + "?a=get-archives-assocfile&site=" + gs.xsltParams.site_name +
     1480                        "&c=" + gs.cgiParams.c + "&d=" + gs.cgiParams.d + "&assocname=" + gs.documentMetadata.Audio;
     1481            if (selectedVersions[0] !== "current") {
     1482               if (selectedVersions[0].includes("Previous")) url += "&dv=" + selectedVersions[0].replace("Previous(", "nminus-").replace(")", "");
     1483               else url += "&dv=" + selectedVersions[0];
     1484            }
     1485            wavesurfer.load(url);
     1486         }
    9541487         primaryCaret.src = interface_bootstrap_images + "caret-right-fill.svg";
    9551488         secondaryCaret.src = interface_bootstrap_images + "caret-right.svg";
    9561489      } else {
     1490         if (currCaretIsPrimary) {
     1491            showAudioLoader();
     1492            if (canvasImages[selectedVersions[1]]) {
     1493               drawImageOnWaveform(canvasImages[selectedVersions[1]]);
     1494               // hideAudioLoader();
     1495            }
     1496            // else showAudioLoader();
     1497            let url = gs.variables.metadataServerURL + "?a=get-archives-assocfile&site=" + gs.xsltParams.site_name +
     1498                        "&c=" + gs.cgiParams.c + "&d=" + gs.cgiParams.d + "&assocname=" + gs.documentMetadata.Audio;
     1499            if (selectedVersions[1] !== "current") {
     1500               if (selectedVersions[1].includes("Previous")) url += "&dv=" + selectedVersions[1].replace("Previous(", "nminus-").replace(")", "");
     1501               else url += "&dv=" + selectedVersions[1];
     1502            }
     1503            wavesurfer.load(url);
     1504         }
    9571505         primaryCaret.src = interface_bootstrap_images + "caret-right.svg";
    9581506         secondaryCaret.src = interface_bootstrap_images + "caret-right-fill.svg";
     
    9601508   }
    9611509
     1510   /**
     1511   * Shows spinning loader over waveform, hides regions
     1512   */
     1513   function showAudioLoader() {
     1514      $('.wavesurfer-region').fadeOut(100);
     1515      $(".chapter").fadeOut(100);
     1516      $(".track-set-label").fadeOut(100);
     1517      waveformSpinner.style.display = 'block';
     1518      loader.style.display = "inline";
     1519      for (const ele of editPanel.children) ele.classList.add("disabled");
     1520      playPauseButton.classList.add("disabled");
     1521   }
     1522
     1523   /**
     1524   * Hides spinning loader, brings back regions
     1525   */
     1526   function hideAudioLoader() {
     1527      $('.wavesurfer-region').fadeIn(100);
     1528      $(".chapter").fadeIn(100);
     1529      $("#track-set-label-top").fadeIn(100);
     1530      if (dualMode) $('#track-set-label-bottom').fadeIn(100);
     1531      waveformSpinner.style.display = 'none';
     1532      loader.style.display = "none";
     1533      for (const ele of editPanel.children) ele.classList.remove("disabled");
     1534      updateRegionEditPanel();
     1535      playPauseButton.classList.remove("disabled");
     1536   }
     1537
     1538   /**
     1539   * Draws given image URL on waveform
     1540   * @param image URL of image to be drawn
     1541   */
     1542   function drawImageOnWaveform(image) {
     1543      // console.log('draw waveform image from cache')
     1544      if (document.getElementById('new-canvas')) document.getElementById('new-canvas').remove();
     1545      var newCanvas = document.createElement("div");
     1546      newCanvas.id = "new-canvas";
     1547      newCanvas.style.width = wavesurfer.drawer.canvases[0].wave.width + 'px';
     1548      newCanvas.style.height = '140px';
     1549      newCanvas.style.backgroundImage = "url('" + image + "')";
     1550      waveformContainer.appendChild(newCanvas);
     1551   }
     1552
     1553   /**
     1554   * Regenerates chapter list to update any changes made in speakerSet
     1555   */
    9621556   function reloadChapterList() {
    9631557      chapters.innerHTML = "";
     
    9691563         speakerName.classList.add("speakerName");
    9701564         speakerName.innerText = currSpeakerSet.tempSpeakerObjects[i].speaker;
     1565         let regionLocked = document.createElement("img");
     1566         regionLocked.src = interface_bootstrap_images + "lock.svg";
     1567         regionLocked.classList.add("speakerLocked", "hide");
     1568         attachPadlockListener(regionLocked, currSpeakerSet.tempSpeakerObjects[i].region, true);
     1569         if (currSpeakerSet.tempSpeakerObjects[i].locked && editMode) regionLocked.classList.remove("hide");
    9711570         let speakerTime = document.createElement("span");
    9721571         speakerTime.classList.add("speakerTime");
    9731572         speakerTime.innerHTML = minutize(currSpeakerSet.tempSpeakerObjects[i].start) + " - " + minutize(currSpeakerSet.tempSpeakerObjects[i].end) + "s";
    9741573         chapter.appendChild(speakerName);
     1574         chapter.appendChild(regionLocked);
    9751575         chapter.appendChild(speakerTime);
    9761576         chapter.addEventListener("click", chapterClicked);
     
    9851585   }
    9861586
    987    wavesurfer.on("play", () => { playPauseButton.src = interface_bootstrap_images + "pause.svg"; });
    988    wavesurfer.on("pause", () => { playPauseButton.src = interface_bootstrap_images + "play.svg"; });
    989    wavesurfer.on("mute", function(mute) {
    990       if (mute) {
    991          muteButton.src = interface_bootstrap_images + "mute.svg";
    992          muteButton.style.opacity = 0.6;
    993          volumeSlider.value = 0;
    994       }
    995       else {
    996          muteButton.src = interface_bootstrap_images + "unmute.svg";
    997          muteButton.style.opacity = 1;
    998          volumeSlider.value = 1;
    999       }
    1000    });
    1001 
    1002    volumeSlider.addEventListener("input", function() {
    1003       wavesurfer.setVolume(this.value);
    1004       if (this.value == 0) {
    1005          muteButton.src = interface_bootstrap_images + "mute.svg";
    1006          muteButton.style.opacity = 0.6;
    1007       } else {
    1008          muteButton.src = interface_bootstrap_images + "unmute.svg";
    1009          muteButton.style.opacity = 1;
    1010       }
    1011    });
    1012 
    1013    zoomSlider.addEventListener("input", function() { // slider changes waveform zoom
    1014       wavesurfer.zoom(Number(this.value) / 4);
    1015       if (currentRegion.speaker && getCurrentRegionIndex() != -1) {
    1016          setHoverSpeaker(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region.element.style.left, currentRegion.speaker);
    1017          drawCurrentRegionBounds();
    1018       }
    1019       let handles = document.getElementsByClassName("wavesurfer-handle");
    1020       if (this.value < 20) {
    1021          for (const handle of handles) {
    1022             handle.style.setProperty("width", "1px", "important");
    1023          }
    1024       } else {
    1025          for (const handle of handles) {
    1026             handle.style.setProperty("width", "3px", "important");
    1027          }
    1028       }
    1029    });
    1030    wavesurfer.zoom(zoomSlider.value / 4); // set default zoom point
    1031 
    1032    let toggleChapters = function() { // show & hide chapter section
     1587   /**
     1588   * Shows / hides chapter section
     1589   */
     1590   let toggleChapters = function() {
    10331591      if (chapters.style.height == "0px") {
    10341592         chapters.style.height = "90%";
     
    10421600   }
    10431601
     1602   /**
     1603   * Object representing elements of a diarization output
     1604   * @param {boolean} isSecondary Whether or not the set is secondary/bottom (true) or primary/top (false)
     1605   * @param {Array} uniqueSpeakers Array of all unique speaker names within the diarization data, used for colouring regions
     1606   * @param {Array} speakerObjects Array of objects containing speaker start/stop times and names
     1607   * @param {Array} tempSpeakerObjects Temporary version of speakerObjects, which can be reverted back to if required
     1608   */
    10441609   function SpeakerSet(isSecondary, uniqueSpeakers, speakerObjects, tempSpeakerObjects) {
    10451610      this.isSecondary = isSecondary;
     
    10481613      this.tempSpeakerObjects = tempSpeakerObjects;
    10491614   }
    1050    let primarySet = new SpeakerSet(false, [], [], []);
    1051    let secondarySet = new SpeakerSet(true, [], [], []);
     1615
     1616   let primarySet = new SpeakerSet(false, [], [], [], []);
     1617   let secondarySet = new SpeakerSet(true, [], [], [], []);
    10521618   let currSpeakerSet = primarySet;
    10531619
    1054    function loadCSVFile(filename, manualHeader, speakerSet) { // based on: https://stackoverflow.com/questions/7431268/how-to-read-data-from-csv-file-using-javascript
     1620   /**
     1621   * Reads diarization CSV file and populates speakerSet
     1622   * @param {string} filename Source destination of input CSV file
     1623   * @param {object} speakerSet speaker set to be populated
     1624   * @param {boolean} forcePopulate Forces redraw of regions and chapters
     1625   */
     1626   function loadCSVFile(filename, speakerSet, forcePopulate) { // based on: https://stackoverflow.com/questions/7431268/how-to-read-data-from-csv-file-using-javascript
    10551627      $.ajax({
    10561628         type: "GET",
     
    10601632         let dataLines = data.split(/\r\n|\n/);
    10611633         let headers;
    1062          let startIndex;
     1634         let startIndex = 0;
    10631635         speakerSet.uniqueSpeakers = []; // used for obtaining unique colours
    10641636         speakerSet.speakerObjects = []; // list of speaker items
    10651637
    1066          if (manualHeader) { // headers for columns can be provided if not existent in csv
    1067             headers = manualHeader;
    1068             startIndex = 0;
    1069          } else {
    1070             headers = dataLines[0].split(',');
    1071             startIndex = 1;
    1072          }
     1638         if (dataLines[0].split(',').length === 3) headers = ["speaker", "start", "end"]; // assume speaker, start, end
     1639         else if (dataLines[0].split(',').length === 4) headers = ["speaker", "start", "end", "locked"]; // assume speaker, start, end, locked
    10731640
    10741641         for (let i = startIndex; i < dataLines.length; i++) {
     
    10821649                  }
    10831650               }
     1651               if (headers.length === 3) item['locked'] = false;
    10841652               speakerSet.speakerObjects.push(item);
    10851653            }
    10861654         }
    10871655         speakerSet.tempSpeakerObjects = cloneSpeakerObjectArray(speakerSet.speakerObjects);
    1088          populateChapters(speakerSet);
     1656         if (!speakerSet.isSecondary || forcePopulate) populateChaptersAndRegions(speakerSet); // prevents secondary set being drawn on first load
    10891657         resetUndoStates(); // undo stack init
    1090       });
    1091    }
    1092 
    1093    function populateChapters(data) { // populates chapter section and adds regions to waveform
     1658      }, (error) => { console.log("loadCSVFile error:"); console.log(error); });
     1659   }
     1660
     1661   /**
     1662   * Populates chapter list div and regions on waveform with given speaker set
     1663   * @param {object} data Speaker set object with diarization data
     1664   */
     1665   function populateChaptersAndRegions(data) {
    10941666      // colorbrewer is a web tool for guidance in choosing map colour schemes based on a letiety of settings.
    10951667      // this colour scheme is designed for qualitative data
    1096 
    10971668      if (regionColourSet.length < 1) {
    10981669         for (let i = 0; i < data.uniqueSpeakers.length; i++) { // not tested in cases where there are more than 8 speakers!!
     
    11071678      data.tempSpeakerObjects = sortSpeakerObjectsByStart(data.tempSpeakerObjects); // sort speakerObjects by start time
    11081679      if (isSelectedSet || !dualMode) chapters.innerHTML = ""; // clear chapter div for re-population
    1109 
    11101680      for (let i = 0; i < data.tempSpeakerObjects.length; i++) {
    11111681         let chapter = document.createElement("div");
     
    11151685         speakerName.classList.add("speakerName");
    11161686         speakerName.innerText = data.tempSpeakerObjects[i].speaker;
     1687         let regionLocked = document.createElement("img");
     1688         regionLocked.src = interface_bootstrap_images + "lock.svg";
     1689         regionLocked.classList.add("speakerLocked", "hide");
     1690         attachPadlockListener(regionLocked, data.tempSpeakerObjects[i].region, true);
     1691         if (data.tempSpeakerObjects[i].locked && editMode) regionLocked.classList.remove("hide");
    11171692         let speakerTime = document.createElement("span");
    11181693         speakerTime.classList.add("speakerTime");
    11191694         speakerTime.innerHTML = minutize(data.tempSpeakerObjects[i].start) + " - " + minutize(data.tempSpeakerObjects[i].end) + "s";
    11201695         chapter.appendChild(speakerName);
     1696         chapter.appendChild(regionLocked);
    11211697         chapter.appendChild(speakerTime);
    11221698         chapter.addEventListener("click", chapterClicked);
     
    11511727               label: speakerName,
    11521728            },
    1153             // color: colourbrewerSet[data.uniqueSpeakers.indexOf(data.tempSpeakerObjects[i].speaker)%8] + regionTransparency,
    11541729            color: regColour + regionTransparency,
    11551730            ...(selected) && {color: "rgba(255,50,50,0.5)"},
    11561731         });
    11571732         data.tempSpeakerObjects[i].region = associatedReg;
    1158       }
    1159 
     1733         if (selected && data.tempSpeakerObjects[i].locked) { // add padlock to regions if they are selected and locked
     1734            let lock = drawPadlock(associatedReg.element);
     1735            attachPadlockListener(lock, associatedReg, false);
     1736         }
     1737         if (selected) drawMenuButton(associatedReg);
     1738      }
     1739      if (waveformSpinner.style.display == 'block') $(".wavesurfer-region").fadeOut(100); // keep regions hidden until wavesurfer.load() has finished
    11601740      let handles = document.getElementsByTagName('handle');
    11611741      for (const handle of handles) handle.addEventListener('mousedown', () => mouseDown = true);
     
    11631743      let regions = document.getElementsByTagName("region");
    11641744      if (dualMode) {
    1165          if (document.getElementsByClassName("region-top").length === 0) for (const reg of regions) reg.classList.add("region-top");
    1166          else for (const rego of regions) if (!rego.classList.contains("region-top")) rego.classList.add("region-bottom");
     1745         if (document.getElementsByClassName("region-top").length == 0) {
     1746            for (const reg of regions) {
     1747               if (reg.classList.length == 1) reg.classList.add("region-top");
     1748            }
     1749         } else {
     1750            for (const rego of regions) {
     1751               if (!rego.classList.contains("region-top") && rego.classList.length == 1) rego.classList.add("region-bottom");
     1752            }
     1753         }
    11671754      }
    11681755      if (editMode) for (const reg of regions) reg.style.setProperty("z-index", "3", "important");
     
    11771764         url: filename,
    11781765         dataType: "text",
    1179       }).then(function(data){ populateWords(JSON.parse(data)) });
     1766      }).then(function(data){ populateWords(JSON.parse(data)) }, (error) => { console.log("loadJSONFile error:"); console.log(error); });
    11801767   }
    11811768
     
    12391826      }
    12401827   }
    1241 
     1828   /**
     1829   * Handles region and chapter colours
     1830   * @param {object} region Region element to adjust
     1831   * @param {boolean} highlight Whether or not region should be white-highlighted
     1832   */
    12421833   function handleRegionColours(region, highlight) { // handles region, chapter & word colours
    12431834      if (!dualMode || (region.element.classList.contains("region-top") && primaryCaret.src.includes("fill")) || region.element.classList.contains("region-bottom") && secondaryCaret.src.includes("fill")) {
    12441835         let colour;
    12451836         if (highlight) {
    1246             colour = "rgb(101, 116, 116)";
     1837            colour = "rgb(81, 90, 90)";
    12471838            regionEnter(region);
    12481839         } else {
     
    12531844            colour = "rgba(255, 50, 50, 0.5)";
    12541845         }
    1255          chapters.childNodes[region.id.replace("region","")].style.backgroundColor = colour;
     1846         if (chapters.childNodes[getIndexOfRegion(region)]) chapters.childNodes[getIndexOfRegion(region)].style.backgroundColor = colour;
    12561847      }
    12571848   }
    12581849
    12591850   function regionEnter(region) {
    1260       // console.log("regionEnter");
    12611851      if (isCurrentRegion(region) || isInCurrentRegions(region)) {
    12621852         region.update({ color: "rgba(255, 50, 50, 0.5)" });
    12631853      } else {
    1264          region.update({ color: "rgba(255, 255, 255, 0.35)" });
     1854         region.update({ color: "rgba(255, 255, 255, 0.3)" });
     1855      }
     1856      if (editMode && currSpeakerSet.tempSpeakerObjects[getIndexOfRegion(region)] && currSpeakerSet.tempSpeakerObjects[getIndexOfRegion(region)].locked
     1857         && region.element.getElementsByClassName("region-padlock").length == 0) { // hovered region is locked
     1858         let lock = drawPadlock(region.element);
     1859         attachPadlockListener(lock, region, false);
     1860      }
     1861      if (editMode && region.element.getElementsByClassName("region-menu").length == 0) {
     1862         drawMenuButton(region);
    12651863      }
    12661864   }
     
    12701868         if (isCurrentRegion(region) || isInCurrentRegions(region)) {
    12711869            region.update({ color: "rgba(255, 50, 50, 0.5)" });
    1272          } else if (!(wavesurfer.getCurrentTime() + 0.1 < region.end && wavesurfer.getCurrentTime() > region.start)) {
     1870         // } else if (!(wavesurfer.getCurrentTime() + 0.1 < region.end && wavesurfer.getCurrentTime() > region.start)) {
     1871         } else {
    12731872            let index = region.id.replace("region", "");
    12741873            region.update({ color: regionColourSet.find(item => item.name === currSpeakerSet.tempSpeakerObjects[index].speaker).colour + regionTransparency });
     1874         }
     1875         if (region.element.getElementsByTagName("img").length > 0 && !isCurrentRegion(region) && !isInCurrentRegions(region)) {
     1876            for (let child of Array.from(region.element.children)) {
     1877               if (child.tagName == "IMG") {
     1878                  child.remove();
     1879               }
     1880            }
    12751881         }
    12761882      } else {
     
    12911897
    12921898   function getLetter(val) {
    1293       // return val.replace("SPEAKER_","");
    12941899      let speakerNum = parseInt(val.replace("SPEAKER_",""));
    12951900      return String.fromCharCode(65 + speakerNum); // 'A' == UTF-16 65
    12961901   }
    12971902
    1298 
    1299 
    1300    // edit functionality
    1301 
    1302    function toggleEditMode() { // toggles edit panel and redraws regions with resize handles
     1903   function toggleEditMode(skipDualModeToggle) { // toggles edit panel and redraws regions with resize handles
    13031904      if (gs.variables.allowEditing === '1') {
    1304          if (dualMode) dualModeCheckbox.click(); // dual mode is disabled when leaving edit mode
    13051905         toggleEditPanel();
    13061906         updateRegionEditPanel();
    1307          drawVersionNames();
    1308       }
    1309    }
    1310 
    1311    function drawVersionNames() {
    1312       if (document.getElementById("prim-set-label")) document.getElementById("prim-set-label").remove();
    1313       if (document.getElementById("sec-set-label")) document.getElementById("sec-set-label").remove();
    1314       if (editMode && !document.body.contains(loader)) { // editmode is opposite here
    1315          let dataLabel = document.createElement("span");
    1316          dataLabel.textContent = gs.documentMetadata.Title + " V1.0";
    1317          dataLabel.id = "prim-set-label";
    1318          waveformContainer.prepend(dataLabel);
    1319          if (dualMode) {
    1320             let dataLabel = document.createElement("span");
    1321             dataLabel.textContent = gs.documentMetadata.Title + " V2.0";
    1322             dataLabel.id = "sec-set-label";
    1323             waveformContainer.prepend(dataLabel);
    1324          }
    1325       }
     1907         reloadChapterList();
     1908      }
     1909   }
     1910
     1911   function toggleVersionDropdown(e) {
     1912      e.stopPropagation();
     1913      if (versionSelectMenu.classList.contains("visible")) {
     1914         e.target.style.display = 'inline';
     1915         versionSelectMenu.classList.remove("visible");
     1916      }
     1917      else {
     1918         e.target.style.display = 'none';
     1919         versionSelectMenu.classList.add("visible");
     1920         versionSelectMenu.style.top = "2rem";
     1921         versionSelectMenu.style.height = wave.clientHeight + wavesurfer.timeline.container.clientHeight + document.getElementById("audio-toolbar").clientHeight - 6 + "px";
     1922         if (e.target.parentElement.id.includes("top")) versionSelectMenu.classList.add("versionTop");
     1923         else versionSelectMenu.classList.remove("versionTop");
     1924         for (version of versionSelectMenu.children) { // handle disabling of regions if being viewed
     1925            if (selectedVersions.includes(version.id) || selectedVersions.includes(version.innerText)) version.classList.add('disabled');
     1926            else version.classList.remove('disabled');
     1927         }
     1928      }
    13261929   }
    13271930
     
    13301933      hoverSpeaker.innerHTML = "";
    13311934      if (editPanel.style.height == "0px") {
    1332          if (chapters.style.height == "0px") { // expands chapter panel
    1333             toggleChapters();
    1334          }
     1935         if (chapters.style.height == "0px") toggleChapters(); // expands chapter panel
    13351936         editPanel.style.height = "30vh";
    1336          editPanel.style.padding = "1rem";
     1937         editPanel.style.padding = "0.5rem";
    13371938         setRegionEditMode(true);
    13381939      } else {
     
    13431944   }
    13441945
    1345    function setRegionEditMode(state) {
     1946   function setRegionEditMode(state) { 
    13461947      editMode = state;
    13471948      chapters.innerHTML = '';
    1348       wavesurfer.clearRegions();
    1349       populateChapters(currSpeakerSet);
    1350    }
    1351 
     1949      $('.wavesurfer-region').hide();
     1950      reloadRegionsAndChapters(); // editMode sets drag/resize property when regions are redrawn
     1951   }
     1952
     1953   /**
     1954   * Handles the edit of region start time, stop time, or speaker name, updating the speaker set
     1955   * @param {object} region Region that has been updated
     1956   */
    13521957   function handleRegionEdit(region, e) {
    13531958      if (region.element.classList.contains("region-bottom")) { currSpeakerSet = secondarySet; swapCarets(false) }
     
    13551960      editsMade = true;
    13561961      currentRegion = region;
    1357       region.play();
    1358       wavesurfer.pause();
     1962      wavesurfer.backend.seekTo(region.start);
    13591963      let regionIndex = getCurrentRegionIndex();
    13601964      currentRegion.speaker = currSpeakerSet.tempSpeakerObjects[regionIndex].speaker;
     
    13691973      handleSameSpeakerOverlap(getCurrentRegionIndex(), currSpeakerSet); // recalculate index in case start pos has changed
    13701974      addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "dragdrop", getCurrentRegionIndex());
     1975      editLockedRegion(currSpeakerSet.tempSpeakerObjects[regionIndex], chaps);
     1976
    13711977      editPanel.click(); // fixes buttons needing to be clicked twice (unknown cause!)
    13721978   }
    13731979
    1374    function handleSameSpeakerOverlap(regionIdx, speakerSet) { // consumes/merges same-speaker regions with overlapping bounds
     1980   /**
     1981   * Shows popup to ensure user is aware they are editing a locked region
     1982   * @param {object} region Region that is being edited
     1983   */
     1984   function editLockedRegion(region) { // ensures user is aware region being edited is locked
     1985      if (region.locked) {
     1986         let confirm = false;
     1987         confirm = window.confirm("Editing a locked region will unlock it, are you sure you want to continue?");
     1988         if (!confirm) undo(); // undo change if no
     1989         else { // remove lock if yes
     1990            region.locked = false;
     1991            if (region.region && region.region.element.firstChild) region.region.element.firstChild.remove(); // remove region padlock
     1992            if (chapters.childNodes[getCurrentRegionIndex()] && chapters.childNodes[getCurrentRegionIndex()].childNodes[1].tagName === "IMG") {
     1993               chapters.childNodes[getCurrentRegionIndex()].childNodes[1].classList.add('hide'); // remove chapter padlock
     1994            }
     1995         }
     1996      }
     1997   }
     1998
     1999   /**
     2000   * Merges same-speaker regions with overlapping bounds
     2001   * @param {int} regionIdx Index of dragged/edited region
     2002   * @param {object} speakerSet Speaker set dragged region exists in
     2003   * @param {boolean} skipCurrentRegionUpdate Whether or not to skip the updating of current region
     2004   */
     2005   function handleSameSpeakerOverlap(regionIdx, speakerSet, skipCurrentRegionUpdate) {
    13752006      let draggedRegion = speakerSet.tempSpeakerObjects[regionIdx]; // regionIdx may point to a different region within the for-loop after adjustments, so defined here
    13762007      let draggedRegionSpeaker = draggedRegion.speaker;
     
    13812012               draggedRegion.end = Math.max(speakerSet.tempSpeakerObjects[i].end, draggedRegion.end);
    13822013               draggedRegion.region.update({start: Math.min(speakerSet.tempSpeakerObjects[i].start, draggedRegion.start), end: Math.max(speakerSet.tempSpeakerObjects[i].end, draggedRegion.end)});
    1383                currentRegion = draggedRegion;
     2014               if (!skipCurrentRegionUpdate) currentRegion = draggedRegion;
    13842015               speakerSet.tempSpeakerObjects[i].region.remove();
    13852016               speakerSet.tempSpeakerObjects.splice(i, 1); // remove consumed region
     
    14012032   }
    14022033
    1403    function updateRegionEditPanel() { // updates edit panel content/inputs
    1404       // console.log('updating regionEditPanel')
     2034   /**
     2035   * Updates the edit panel elements based on various editing states
     2036   */
     2037   function updateRegionEditPanel() {
    14052038      if (currentRegion && currentRegion.speaker == "") {
    14062039         removeButton.classList.add("disabled");
     
    14412074   }
    14422075
     2076   /**
     2077   * Adds a new region to the waveform at the current caret location with the speaker name "NEW_SPEAKER"
     2078   */
    14432079   function createNewRegion() { // adds a new region to the waveform
    14442080      clearChapterSearch();
     
    14472083      const start = newRegionOffset + wavesurfer.getCurrentTime();
    14482084      const end = newRegionOffset + wavesurfer.getCurrentTime() + 15;
    1449       newRegionOffset += 5; // offset new region if multiple new regions are created. TODO: check region has different start time
     2085      newRegionOffset += 5; // offset new region if multiple new regions are created.
    14502086      currSpeakerSet.tempSpeakerObjects.push({speaker: speaker, start: start, end: end});
    14512087
     
    14672103   }
    14682104
    1469    function removeRegion() { // removes currently selected region or regions
     2105   /**
     2106   * Removes the currently selected region or regions
     2107   */
     2108   function removeRegion() {
    14702109      if (!removeButton.classList.contains("disabled")) {
    14712110         if (getCurrentRegionIndex() != -1) { // if currentRegion has been set
    14722111            let currentRegionIndex = getCurrentRegionIndex();
    14732112            let currentRegionIndexes = getCurrentRegionsIndexes();
     2113            let lockTemplate = { locked: currSpeakerSet.tempSpeakerObjects[currentRegionIndex].locked };
    14742114            for (let i = 0; i < currSpeakerSet.tempSpeakerObjects.length; i++) {
    14752115               if (isCurrentRegion(currSpeakerSet.tempSpeakerObjects[i].region)) {
     
    14822122                     addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "remove", currentRegionIndex);
    14832123                     updateRegionEditPanel();
     2124                     reloadChapterList();
     2125                     editLockedRegion(lockTemplate);
    14842126                     return; // jump out of function
    14852127                  }
     
    14932135            addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "remove", currentRegionIndex, currentRegionIndexes); // multiple regions removed
    14942136            updateRegionEditPanel();
     2137            reloadChapterList();
     2138            editLockedRegion(lockTemplate);
    14952139         } else { console.log("no region selected") }
    14962140      }
     
    15542198   }
    15552199
    1556    function speakerChange() { // speaker input name onInput handler
     2200   /**
     2201   * Changes the associated speaker name of a region, updating the speaker set
     2202   */
     2203   function speakerChange() {
    15572204      const newSpeaker = speakerInput.value;
    15582205      clearChapterSearch();
    1559       if (newSpeaker && newSpeaker != "") {
     2206      if (newSpeaker && newSpeaker.trim() != "") {
    15602207         speakerInput.style.outline = "2px solid transparent";
    15612208         if (getCurrentRegionIndex() != -1) { // if a region is selected
     
    15772224            editsMade = true;
    15782225            addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "speaker-change", getCurrentRegionIndex(), getCurrentRegionsIndexes());
     2226            editLockedRegion(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()]);
    15792227         } else { console.log("no region selected") }
    15802228      } else { console.log("no text in speaker input"); speakerInput.style.outline = "2px solid firebrick"; }
     
    15902238   }
    15912239
     2240   /**
     2241   * Selects all (or reverts select-all) regions matching any of the currently selected speaker names
     2242   * @param {boolean} skipUndoState Whether or not to skip the addition of an undo state
     2243   */
    15922244   function selectAllCheckboxChanged(skipUndoState) { // "Change all" toggled
    15932245      if (changeAllCheckbox.checked) {
     
    16242276
    16252277   function disableStartEndInputs() { // adds the 'disabled' tag to all time inputs
    1626       for (idx in startTimeInput.childNodes) { startTimeInput.childNodes[idx].disabled = true; startTimeInput.childNodes[idx].value = 0; }
    1627       for (idx in endTimeInput.childNodes) { endTimeInput.childNodes[idx].disabled = true; endTimeInput.childNodes[idx].value = 0; }
    1628    }
    1629 
    1630    function zoomTo(dest) { // (smoothly?) zooms wavesurfer waveform to destination
     2278      for (idx in startTimeInput.childNodes) { startTimeInput.childNodes[idx].value = 0; startTimeInput.childNodes[idx].disabled = true; }
     2279      for (idx in endTimeInput.childNodes) { endTimeInput.childNodes[idx].value = 0; endTimeInput.childNodes[idx].disabled = true; }
     2280   }
     2281
     2282   /**
     2283   * Zooms wavesurfer waveform to destination zoom level, used in select all function
     2284   * @param {number} dest Destination zoom level
     2285   */
     2286   function zoomTo(dest) {
    16312287      isZooming = true;
    16322288      changeAllCheckbox.disabled = true;
     
    16572313            }
    16582314         }
    1659       }, 10); // interval
    1660      
     2315      }, 10); // 10ms interval
     2316   }
     2317
     2318   function toggleSavePopup() { // shows / hides commit popup div
     2319      savePopupCommitMsg.value = savePopupCommitMsg.value.trim(); // clears initial whitespace caused by <xsl: text>
     2320      if (savePopup.classList.contains("visible")) {
     2321         savePopup.classList.remove("visible");
     2322         savePopupBG.classList.remove("visible");
     2323      } else {
     2324         savePopup.classList.add("visible");
     2325         savePopupBG.classList.add("visible");
     2326         savePopup.children[0].innerText = "Commit changes for: " + selectedVersions[(!dualMode || primaryCaret.src.includes("fill")) ? 0 : 1];
     2327      }
    16612328   }
    16622329
    16632330   function saveRegionChanges() { // saves tempSpeakerObjects to speakerObjects
    16642331      if (!saveButton.classList.contains("disabled")) {
    1665          currSpeakerSet.speakerObjects = cloneSpeakerObjectArray(currSpeakerSet.tempSpeakerObjects);
    1666          editsMade = false;
    1667          removeCurrentRegion();
    1668          reloadRegionsAndChapters();
    1669          console.log("saved changes");
    1670       }
    1671    }
    1672 
    1673    function discardRegionChanges() { // resets tempSpeakerObjects to speakerObjects
    1674       if (!discardButton.classList.contains("disabled")) {
    1675          let confirm = window.confirm("Are you sure you want to discard changes?");
    1676          if (confirm) {
     2332         toggleSavePopup();
     2333         // old save functionality
     2334         // currSpeakerSet.speakerObjects = cloneSpeakerObjectArray(currSpeakerSet.tempSpeakerObjects);
     2335         // editsMade = false;
     2336         // removeCurrentRegion();
     2337         // reloadRegionsAndChapters();
     2338         // console.log("saved changes.");
     2339      }
     2340   }
     2341
     2342   /**
     2343   * Commits changes made to the currently selected set to Greenstone's version history system.
     2344   * Firstly increments FLDV, then saves commit message to document's metadata, then sets document's
     2345   * associated file to tempSpeakerObjects CSV.
     2346   */
     2347   function commitChanges() {
     2348      if (savePopupCommitMsg.value && savePopupCommitMsg.value.length > 0) {
     2349         console.log('committing with message: ' + savePopupCommitMsg.value);
     2350         // inc fldv_history
     2351         $.ajax({
     2352            type: "GET",
     2353            url: mod_meta_base_url,
     2354            data: { "o": "json", "s1.a": "inc-fldv-nminus1" }
     2355         }).then((out) => {
     2356            console.log('fldv inc success with status code: ' + out.page.pageResponse.status.code);
     2357            if (out.page.pageResponse.status.code == 11) { // more information on codes found in: GSStatus.java
     2358               ajaxSetCommitMeta();
     2359            }
     2360         }, (error) => { console.log("inc-fldv-nminus1 error:\n" + error) });
     2361         toggleSavePopup();
     2362      } else {
     2363         window.alert("Commit message cannot be left empty.");
     2364      }
     2365   }
     2366
     2367   function ajaxSetCommitMeta() { // saves commit message to current document's metadata
     2368      $.ajax({
     2369         type: "GET",
     2370         url: mod_meta_base_url,
     2371         data: { "o" : "json", "s1.a": "set-archives-metadata", "s1.metaname": "commitmessage", "s1.metavalue": savePopupCommitMsg.value.trim(), "s1.metamode": "override" },
     2372      }).then((out) => {
     2373         console.log('commit success with status code: ' + out.page.pageResponse.status.code);
     2374         if (out.page.pageResponse.status.code == 11) {
     2375            ajaxSetAssocFile();
     2376         }
     2377      }, (error) => { console.log("commit_msg_url error:"); console.log(error); });
     2378   }
     2379
     2380   function ajaxSetAssocFile() { // sets current document's associated file to tempSpeakerObjects
     2381         $.ajax({
     2382            type: "POST",
     2383            url: gs.xsltParams.library_name,
     2384            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,
     2385                    "s1.a": "set-archives-assocfile", "s1.assocname": "structured-audio.csv", "s1.filedata": speakerObjToCSVText() },
     2386         }).then((out) => {
     2387            console.log('set-archives-assocfile success with status code: ' + out.page.pageResponse.status.code);
     2388            resetUndoStates();
     2389         }, (error) => { console.log("set_assoc_url error:"); console.log(error); });
     2390   }
     2391
     2392   function speakerObjToCSVText() { // converts tempSpeakerObjects to csv-like string
     2393      console.log(currSpeakerSet.tempSpeakerObjects.map(item => [item.speaker, item.start, item.end, item.locked]).join('\n'));
     2394      return currSpeakerSet.tempSpeakerObjects.map(item => [item.speaker, item.start, item.end, item.locked]).join('\n');
     2395   }
     2396
     2397   function discardRegionChanges(forceDiscard) { // resets tempSpeakerObjects to speakerObjects
     2398      if (!discardButton.classList.contains("disabled") || forceDiscard) {
     2399         let confirm = false;
     2400         if (!forceDiscard) { confirm = window.confirm("Are you sure you want to discard changes?"); }
     2401         if (confirm || forceDiscard) {
    16772402            currSpeakerSet.tempSpeakerObjects = cloneSpeakerObjectArray(currSpeakerSet.speakerObjects);
    16782403            editsMade = false;
     
    16852410   }
    16862411
     2412   /**
     2413   * Redraws edit panel, chapter list and wavesurfer regions from speaker set
     2414   */
    16872415   function reloadRegionsAndChapters() { // redraws edit panel, chapter list, wavesurfer regions
    16882416      updateRegionEditPanel();
    1689       wavesurfer.clearRegions();
    16902417      $(".region-top").remove();
    16912418      $(".region-bottom").remove();
    16922419      $(".wavesurfer-region").remove();
    1693       populateChapters(primarySet);
     2420      populateChaptersAndRegions(primarySet);
    16942421      if (dualMode) {
    1695          populateChapters(secondarySet);
     2422         populateChaptersAndRegions(secondarySet);
    16962423         currSpeakerSet = primarySet;
    16972424      }
     
    17032430      if (currentRegions.length < 1) {
    17042431         removeButton.innerHTML = "Remove Selected Region";
    1705          enableStartEndInputs();
     2432         // enableStartEndInputs();
    17062433      } else {
    17072434         removeButton.innerHTML = "Remove Selected Regions (x" + currentRegions.length + ")";
    1708          disableStartEndInputs();
    17092435         const uniqueSelectedSpeakers = [... new Set(currentRegions.map(a => a.speaker))]; // gets unique speakers in currentRegions
    17102436         uniqueSelectedSpeakers.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
     
    17132439   }
    17142440
     2441   /**
     2442   * Handles the change of a region's start or end time, updating hte speaker set
     2443   */
    17152444   function changeStartEndTime(e) { // start/end time input handler
    17162445      let newStart = getTimeInSecondsFromInput(startTimeInput);
     
    17352464         handleSameSpeakerOverlap(currRegIdx, currSpeakerSet);
    17362465         addUndoState(primarySet, secondarySet, currSpeakerSet.isSecondary, dualMode, "change-time", getCurrentRegionIndex());
     2466         editLockedRegion(currSpeakerSet.tempSpeakerObjects[currRegIdx]);
    17372467      } else {
    17382468         console.log("no region selected");
     
    17422472   }
    17432473
    1744    function getTimeInSecondsFromInput(input) { // returns time in seconds from start or end input
     2474   /**
     2475   * Calculates time in seconds of start or end time input group
     2476   * @param {element} input Element of time input groups: hh:mm:ss
     2477   * @returns {int} Time in seconds
     2478   */
     2479   function getTimeInSecondsFromInput(input) {
    17452480      let hours = input.children[0].valueAsNumber;
    17462481      let mins = input.children[1].valueAsNumber;
     
    17492484   }
    17502485
     2486   /**
     2487   * Sets the start or end time element group inputs
     2488   * @param {element} input Element of time input group to be updated
     2489   * @param {int} seconds Duration in seconds to be converted into hh:mm:ss
     2490   */
    17512491   function setInputInSeconds(input, seconds) { // sets start or end input time when given seconds
    17522492      let date = new Date(null);
     
    17592499         if (e.classList.contains("seconds") && !e.value.includes(".")) { e.value = e.value + ".0"; }
    17602500         else if (e.value.length === 1){ e.value = '0' + e.value; }// 0 padded on left
    1761          // if (e.value.length === 3) {e.value = '0' + e.value ; console.log('3: ' + e.value)} // 0 on the left (doesn't work on FF)
    17622501      });     
    17632502   }
    17642503
     2504   /**
     2505    * Adds a new undo state to the global undo state list
     2506    * @param {object} state Primary set at current state
     2507    * @param {object} secState Secondary set at current state
     2508    * @param {boolean} isSec Whether or not current change was made to primary (false) or secondary (true) set
     2509    * @param {boolean} dualMode Whether or not audio editor was in dual mode when undo state was added
     2510    * @param {string} type Type of change e.g "remove", "speaker-change"
     2511    * @param {int} currRegIdx Index of currently selected region (for restoration)
     2512    * @param {Array} currRegIdxs Index of currently selected regions, if applicable (for restoration)
     2513    */
    17652514   function addUndoState(state, secState, isSec, dualMode, type, currRegIdx, currRegIdxs) { // adds a new state to the undoStates stack
    17662515      let newState = cloneSpeakerObjectArray(state.tempSpeakerObjects); // clone method removes references
    17672516      let newSecState = cloneSpeakerObjectArray(secState.tempSpeakerObjects); // clone method removes references
     2517      let changedTrack = (type == "dualModeChange" || type == "selectAllChange") ? "none" : selectedVersions[isSec ? 1 : 0] // sets changedTrack to version name of edited region set
    17682518      undoButton.classList.remove("disabled");
    17692519      undoStates = undoStates.slice(0, undoLevel + 1); // trim to current level if undos have already been made
    1770       undoStates.push({state: newState, secState: newSecState, isSec: isSec, dualMode: dualMode, currentRegionIndex: currRegIdx, currentRegionIndexes: currRegIdxs, type: type});
     2520      undoStates.push({state: newState, secState: newSecState, isSec: isSec, changedTrack: changedTrack, dualMode: dualMode, currentRegionIndex: currRegIdx, currentRegionIndexes: currRegIdxs, type: type});
    17712521      if ((type === "change-time" && prevUndoState === "change-time") || (type === "speaker-change" && prevUndoState === "speaker-change")) { // checks if similar change was made previously
    17722522         undoStates.splice(-2, 1); // remove second-to-last item in undoStates stack (merge last two changes into one to avoid multiple small edits)
     
    17832533   }
    17842534
    1785    function undo() { // undo action: go back one state in the undoStates stack
     2535   /**
     2536    * Returns to the previous state in the undo state list
     2537    */
     2538   function undo() {
    17862539      if (!undoButton.classList.contains("disabled") && editMode) { // ensure there exist states to undo to
    17872540         clearChapterSearch();
     
    17912544            let adjustedUndoLevel = undoLevel-1;
    17922545            if (undoStates[undoLevel].type == "dualModeChange") { // toggle dual mode
    1793                dualModeCheckbox.checked = !dualMode;
    17942546               dualModeChanged(true);
    17952547            } else if (undoStates[undoLevel].type == "selectAllChange") { // toggle select all
     
    18082560                  else caretClicked("primary-caret");
    18092561                  currentRegion = selectedSpeakerSet.tempSpeakerObjects[undoStates[undoLevel].currentRegionIndex]; // restore previous current state
    1810                   // console.log("undo-ing to index " + undoStates[undoLevel].currentRegionIndex);
    18112562               } else if (undoStates[undoLevel].currentRegionIndex) {
    18122563                  if (!dualMode) selectedSpeakerSet = primarySet;
     
    18242575            }
    18252576            editsMade = true;
    1826            
    18272577            undoLevel--; // decrement undoLevel
    18282578            reloadRegionsAndChapters();
     
    18352585   }
    18362586
    1837    function redo() { // redo action: go forward one state in the undoStates stack
     2587   /**
     2588    * Moves forward one state in the undo state list
     2589    */
     2590   function redo() {
    18382591      if (!redoButton.classList.contains("disabled") && editMode) { // ensure there exist states to redo to
    18392592         clearChapterSearch();
     
    18412594         else {
    18422595            if (undoStates[undoLevel+1].type == "dualModeChange") { // toggle dual mode
    1843                dualModeCheckbox.checked = !dualMode;
    18442596               dualModeChanged(true);
    18452597            } else if (undoStates[undoLevel+1].type == "selectAllChange") { // toggle select all
     
    18502602               secondarySet.tempSpeakerObjects = cloneSpeakerObjectArray(undoStates[undoLevel+1].secState.slice(0)); // set secondary to new state
    18512603               let selectedSpeakerSet;
    1852 
    18532604               // handle currentRegion change
    18542605               removeCurrentRegion();
     
    18652616                     currentRegion = selectedSpeakerSet.tempSpeakerObjects[undoStates[undoLevel+1].currentRegionIndex];
    18662617                  }
    1867 
    1868                   // console.log("redo-ing to index " + undoStates[undoLevel+1].currentRegionIndex);
    18692618                  if (undoStates[undoLevel+1].currentRegionIndexes && undoStates[undoLevel+1].currentRegionIndexes.length > 1) {
    18702619                     for (const idx of undoStates[undoLevel+1].currentRegionIndexes) currentRegions.push(currSpeakerSet.tempSpeakerObjects[idx]);
     
    18732622            }
    18742623            editsMade = true; 
    1875            
    1876            
    18772624            reloadRegionsAndChapters();
    18782625            undoLevel++; // increment undoLevel
     
    18822629         }
    18832630         if (undoLevel < undoStates.length) undoButton.classList.remove("disabled");
    1884          // console.log("new undoLevel: " + undoLevel);
    18852631      }
    18862632   }
    18872633
    18882634   function resetUndoStates() { // clear undo history
    1889       // console.log('resetUndoStates')
    18902635      undoStates = [{state: cloneSpeakerObjectArray(primarySet.tempSpeakerObjects), secState: cloneSpeakerObjectArray(secondarySet.tempSpeakerObjects)}];
    18912636      undoLevel = 0;
     
    19012646         drawCurrentRegionBounds();
    19022647      }
    1903    }
    1904 
    1905    function drawCurrentRegionBounds() {
     2648      if (document.getElementById('new-canvas')) { document.getElementById('new-canvas').style.left = "-" + wave.scrollLeft + 'px' } // update placeholder waveform scroll position
     2649   }
     2650
     2651   function drawCurrentRegionBounds() { // draws bounds of current region
    19062652      removeRegionBounds();
    1907       if (editMode) {
    1908          let currIndexes = getCurrentRegionsIndexes();
    1909          if (getCurrentRegionIndex != 0) drawRegionBounds(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region, wave.scrollLeft, "FireBrick");
    1910          for (let i = 0; i < currIndexes.length; i++) {
    1911             drawRegionBounds(currSpeakerSet.tempSpeakerObjects[currIndexes[i]].region, wave.scrollLeft, "FireBrick");
    1912          }
    1913       }
    1914    }
    1915 
     2653      let currIndexes = getCurrentRegionsIndexes();
     2654      if (getCurrentRegionIndex() != -1) drawRegionBounds(currSpeakerSet.tempSpeakerObjects[getCurrentRegionIndex()].region, wave.scrollLeft, "FireBrick");
     2655      for (let i = 0; i < currIndexes.length; i++) {
     2656         drawRegionBounds(currSpeakerSet.tempSpeakerObjects[currIndexes[i]].region, wave.scrollLeft, "FireBrick");
     2657      }
     2658   }
     2659
     2660   /**
     2661    * Draws bounding 'n' above hovered or selected region
     2662    * @param {object} region Region to have bound drawn for
     2663    * @param {number} scrollPos Scroll position of div, used to offset draw position
     2664    * @param {string} colour Colour to draw bound (black and FireBrick are used)
     2665    */
    19162666   function drawRegionBounds(region, scrollPos, colour) { // draws on canvas to show bounds of hovered/selected region
    19172667      const hoverSpeakerCanvas = document.createElement("canvas");
     
    19202670      hoverSpeakerCanvas.width = audioContainer.clientWidth; // max width of drawn bounds
    19212671      const ctx = hoverSpeakerCanvas.getContext("2d");
    1922 
    1923       ctx.translate(0.5, 0.5); // fixes lineWidth inconsistency
     2672      // ctx.translate(0.5, 0.5); // fixes lineWidth inconsistency
    19242673      ctx.lineWidth = 1;
    19252674      if (colour == "FireBrick") ctx.lineWidth = 3;
     
    19432692   }
    19442693
    1945    function updateCurrSpeakerSet() {
     2694   function updateCurrSpeakerSet() { // updates 'currSpeakerSet' var
    19462695      if (primaryCaret.src.includes("fill")) currSpeakerSet = primarySet;
    19472696      else if (secondaryCaret.src.includes("fill")) currSpeakerSet = secondarySet;
     
    19502699   function cloneSpeakerObjectArray(inputArray) { // clones speakerObjectArray without references (wavesurfer regions)
    19512700      let output = [];
    1952       for (let i = 0; i < inputArray.length; i++) { output.push({speaker: inputArray[i].speaker, start: inputArray[i].start, end: inputArray[i].end }) }
     2701      for (let i = 0; i < inputArray.length; i++) {
     2702         output.push({ speaker: inputArray[i].speaker, start: inputArray[i].start, end: inputArray[i].end, locked: (inputArray[i].locked === "true" || inputArray[i].locked === true) });
     2703      }
    19532704      return output;
    19542705   }
    19552706
    1956    function flashChapters() {
     2707   function flashChapters() { // flashes chapters a lighter colour momentarily to indicate an update/change
    19572708      chapters.style.backgroundColor = "rgb(66, 84, 88)";
    19582709      setTimeout(() => { chapters.style.backgroundColor = "rgb(40, 54, 58)" }, 500);
    19592710   }
    19602711
    1961    function fullscreenChanged() { // fullscreen onChange handler, increases waveform height & adjusts padding/margin
     2712   /** Fullscreen onChange handler, increases waveform height & adjusts padding/margin */
     2713   function fullscreenChanged() {
    19622714      if (!audioContainer.classList.contains("fullscreen")) {
    19632715         audioContainer.classList.add("fullscreen");
    1964          wavesurfer.setHeight(175);
     2716         wavesurfer.setHeight(175); // increase waveform height
     2717         caretContainer.style.paddingLeft = "2rem";
     2718         caretContainer.style.height = wavesurfer.getHeight() + "px"; // set height to waveform height
     2719         audioContainer.prepend(caretContainer); // attach to audioContainer (otherwise doesn't show due to AC being fullscreen)
    19652720      } else  {
    19662721         audioContainer.classList.remove("fullscreen");
    1967          wavesurfer.setHeight(128);
    1968       }
    1969    }
    1970 
    1971    function toggleFullscreen() { // toggles fullscreen mode of audio player/editor
     2722         wavesurfer.setHeight(140);
     2723         caretContainer.style.paddingLeft = "0";
     2724         caretContainer.style.height = wavesurfer.getHeight() + "px";
     2725         audioContainer.parentElement.prepend(caretContainer); // move back up in DOM hierarchy
     2726      }
     2727      setTimeout(() => { // ensures waveform shows 
     2728         zoomOutButton.click();
     2729         zoomInButton.click();
     2730      }, 250);
     2731   }
     2732
     2733   /** Enables / disables the fullscreen view of audio player / editor */
     2734   function toggleFullscreen() {
    19722735      if ((document.fullscreenElement && document.fullscreenElement !== null) ||
    19732736        (document.webkitFullscreenElement && document.webkitFullscreenElement !== null) ||
     
    19762739         document.exitFullscreen();
    19772740      } else {
    1978          audioContainer.requestFullscreen();
     2741         if (audioContainer.requestFullscreen) {
     2742            audioContainer.requestFullscreen();
     2743         } else if (audioContainer.webkitRequestFullscreen) { /* Safari */
     2744            audioContainer.webkitRequestFullscreen();
     2745         } else if (audioContainer.msRequestFullscreen) { /* IE11 */
     2746            audioContainer.msRequestFullscreen();
     2747         }
    19792748      }
    19802749   }
    19812750}
    19822751
     2752/**
     2753 * Formats seconds to hh:mm:ss
     2754 * @param {number} duration
     2755 * @returns {string} Time in hh:mm:ss format
     2756 */
    19832757function formatAudioDuration(duration) {
    19842758   // console.log('duration: ' + duration);
Note: See TracChangeset for help on using the changeset viewer.