// *************************************** // Client javascript for SPX // *************************************** // (c) 2020-2024 SPX Graphics // *************************************** var socket = io(); // Global App State let APPSTATE = "INIT"; // see AppState() let sortable = ""; // a global sortable object so can be disabled at will, see spxinit() let messageSliderOutTimer // a global var for message slider timeout document.onkeydown = checkKey; socket.on('connect', function () { // Added in 1.1.0: if (document.getElementById('logo') && document.getElementById('logo_off')) { document.getElementById('logo').src=document.getElementById('logo_on').src; document.body.style="pointer-events: auto;" // restore clickability hideMessageSlider(); } }); // end connect socket.on('SPXMessage2Extension', function (data) { // Handles messages coming from server to an extension. // All SPX extensions must have 3 tags in their HTML: // // // // // // Also if the extension JS code is a module the // functions will need to be exposed to the window scope. // This is done by adding the following line to the end // of the module: // // window.functionName = functionName; // if (typeof window[data.function] === "function") { // console.log('API invokeExtensionFunction called --> ' + data.function + '(' + data.params + ')'); window[data.function](data.params); } else { console.error('Function not found in extension [' + data.function + ']. Is it exposed?'); } }); // end SPXMessage2Extension socket.on('SPXMessage2Client', function (data) { // Handles messages coming from server to this client. // All comms using 'SPXMessage2Client' as a conduit with data object and // data.spxcmd as function identifier. Additional object values are payload. // console.log('SPXMessage2Client received', data) switch (data.spxcmd) { case 'notifyMultipleControllers': let notification = document.getElementById('severalDetected'); if (notification) { // controller view if (data.count && data.count>1) { console.log(data.count + ' SPX Controllers detected by the server. Displaying notification.'); notification.style.display = 'flex'; } else { notification.style.display = 'none'; } } break; case 'updateServerIndicator': e = document.getElementById(data.indicator); if (e) {e.style.color = data.color;} break; case 'updateStatusText': if (document.getElementById('statusbar')) { document.getElementById('statusbar').innerText = data.status; clearTimeout(localStorage.getItem('SPX_CT_StatusTimer')); statusTimer = setTimeout(function () { document.getElementById('statusbar').innerText = ''; }, 3000); localStorage.setItem('SPX_CT_StatusTimer', statusTimer); } else { console.log('statusbar (element) not found'); } break; case 'showMessageSlider': // Shows a message in the message slider, an extension may initiate this. showMessageSlider(data.msg, data.type, data.persist); // clearTimeout(localStorage.getItem('SPX_CT_StatusTimer')); // statusTimer = setTimeout(function () { document.getElementById('statusbar').innerText = ''; }, 3000); // localStorage.setItem('SPX_CT_StatusTimer', statusTimer); break; case 'clientLostNotification': switch (data.clientName) { case 'SPX_PROGRAM': if (document.getElementById('toggleRendererWindowProgram')) { console.log('SPX_PROGRAM client lost, toggling off'); document.getElementById('toggleRendererWindowProgram').checked = false; } else { console.log('SPX_PROGRAM client lost, no toggle found'); } toggleNormalRenderer('normal'); break; case 'SPX_PREVIEW': if ( document.getElementById('toggleRendererWindowPreview') ) { console.log('SPX_PREVIEW client lost, toggling off'); document.getElementById('toggleRendererWindowPreview').checked = false; } else { console.log('SPX_PREVIEW client lost, no toggle found'); } break; default: break; } break; case 'updateRundownItem': // This is a message to update a single item in the rundown. // Used by API to update the UI after a playback command. let item = getElementByEpoch(data.itemID); if (item) { let itemTemplate = item.querySelector('[name="RundownItem[relpath]"]').value; if (data.relpath == itemTemplate) { // console.log('Setting UI state of "' + data.itemID + '" forcefully to "' + data.command + '".'); playItem(item, data.command, true) // true = prevent playout, because API did it already } else { console.warn('Item template mismatch, not updating any indicators in the UI. Request', data); } } else { console.warn('Requested item ' + data.itemID + ' not found. Not updating any indicators in the UI.'); } break; default: // console.log('Unknown SPXMessage2Client command: ' + data.command); } }); // end SPXMessage2Client socket.on('SPXMessage2Controller', function (data) { // Handles (API) messages coming from server to the SPX-GC Controller. // All comms using 'SPXMessage2Controller' as a conduit with data object and // data.APIcmd as function identifier. Additional object values are payload. // Feature added in v.1.0.8. let el = null; let DomItemID; switch (data.APIcmd) { // Rundown commands below case 'RundownLoad': window.location.href = '/gc/' + data.file break; case 'RundownFocusFirst': focusRow(0) break; case 'RundownFocusNext': moveFocus(1) break; case 'RundownFocusPrevious': moveFocus(-1) break; case 'RundownFocusLast': let lastIndex = document.querySelectorAll('.itemrow').length-1; focusRow(lastIndex) break; case 'RundownFocusByID': focusRow(getElementByEpoch(data.itemID)) break; case 'RundownStopAll': stopAll() break; case 'RundownAllStatesToStopped': // console.log('Turn lights off and set DOM variables. Storage handled separately.') let items = document.querySelectorAll('.itemrow'); items.forEach((item,index) => { item.setAttribute('data-spx-onair', 'false'); item.querySelector('[data-spx-name="icon"]').classList.remove('playTrue'); item.querySelector('[data-spx-name="icon"]').classList.remove('playAuto'); item.querySelector('[data-spx-name="icon"]').classList.add('playFalse'); }); break; // Item commands below case 'ItemPlay': playItem(getFocusedRow(),'play') break; case 'ItemPlayID': el = getElementByEpoch(data.itemID); if (el) { // console.log('ItemPlayID: Element found', data.itemID, el); playItem(el,'play') } else { console.warn('ItemPlayID: Element not found', data.itemID); } break; case 'ItemContinue': nextItem(getFocusedRow()) break; case 'ItemContinueID': el = getElementByEpoch(data.itemID); if (el) { nextItem(el) } else { console.warn('ItemContinueID: Element not found', data.itemID); } break; case 'ItemStop': playItem(getFocusedRow(),'stop') break; case 'ItemStopID': el = getElementByEpoch(data.itemID); if (el) { playItem(el,'stop') } else { console.warn('ItemStopID: Element not found', data.itemID); } break; // Utils case 'showMessageSlider': showMessageSlider(data.msg, data.type, data.persist) break; default: console.log('Unknown SPXMessage2Controller command: ' + data.APIcmd, data); } }); // end SPXMessage2Controller socket.on('disconnect', function () { // Added in 1.1.0 if (document.getElementById('logo') && document.getElementById('logo_off')) { document.getElementById('logo').src=document.getElementById('logo_off').src; showMessageSlider('Disconnected from SPX server', type='error', true) document.body.style="pointer-events: none;" // disable clickability } }); // end disconnect // see also spxInit() // ########################################################################################################## function rundownPopup(mode) { // get current url let url = window.location.href; url = url + '/light' window.open(url); } function addSelectedTemplate(idx) { // alert('Selected index ' + idx); data={}; data.command = "addItemToRundown"; data.foldername = document.getElementById('foldername').value; data.listname = document.getElementById('filebasename').value; data.datafile = document.getElementById('datafile').value; data.templateindex = idx; post('', data, 'post'); } // addSelectedTemplate function addAllTemplatesToRundown() { // Added in 1.2.0 data={}; data.command = "addAllItemsToRundown"; data.foldername = document.getElementById('foldername').value; data.listname = document.getElementById('filebasename').value; data.datafile = document.getElementById('datafile').value; post('', data, 'post'); } // addAllTemplatesToRundown function ajaxpost(urlPath, data, prepopulated='false'){ // This is a utility function using Axios to POST // stuff to server. Here we handle response and // status bar etc... // // Prepopulated -flag prevents reading content // from json files and submits values as-is; // this is used by show- and global extras. if (prepopulated=='true'){ // console.log('Prepop YES'); data.prepopulated="true" }else{ // console.log('Prepop NO'); data.prepopulated="false" }; axios.post(urlPath, data) .then(function (response) { statusbar(response.data); working(''); }) .catch(function (error) { working(''); if (error.response) { // statusbar('SPX server returned error, see console and logs','error') statusbar(error.response.data,'error') showMessageSlider(error.response.data, 'error') // msg, type='info', persist=false console.log(error.response.data); console.log(error.response.status); console.log(error.response.headers); } else if (error.request) { statusbar('SPX server connection error','error') console.log(error.request); } else { // Something happened in setting up the request that triggered an Error statusbar('SPX request error, see console','warn') console.log('Error', error.message); } console.log(error.config); }); } // ajaxpost ended function ajaxpostform(urlPath, FormNro){ } // ajaxpostform ended function aFunctionTest() { // execute a test function on the server // this gets triggered by menu > ping console.log('Sending testfunction request to server. See server console / log with PING -identifier...') AJAXGET('/CCG/testfunction/'); } //aFunctionTest // Filename validation for project/ruindown prompts // Added in 1.3.0 var isValid=(function(){ var rg1=/^[^\\/:\*\?"<>\|]+$/; // forbidden characters \ / : * ? " < > | var rg2=/^\./; // cannot start with dot (.) var rg3=/^\_/; // cannot start with underscore (_) var rg4=/^(nul|prn|con|lpt[0-9]|com[0-9])(\.|$)/i; // forbidden file names return function isValid(str){ return rg1.test(str)&&!rg2.test(str)&&!rg3.test(str)&&!rg3.test(str); } })(); function add(ModalID) { // require ..... nothing // returns ..... posts a name of a file to server which redirects // Improved in 1.3.0 with validation ModalOn(ModalID) document.getElementById('rundownName').focus(); document.getElementById('rundownForm').addEventListener('submit', function(e) { e.preventDefault(); const rundownName = document.getElementById('rundownName').value.trim(); if (!isValid(rundownName)) { alert("Please enter a valid rundown name, no special characters."); return; } if (rundownName != null && rundownName != "") { post('', { filebasename: rundownName }, 'post'); } }); document.getElementById('cancelForm').addEventListener('click', function() { ModalOff(ModalID); }) } //add function addshow(ModalID) { // require ..... nothing // returns ..... posts a name of a folder to server which redirects // Improved in 1.3.0 with validation ModalOn(ModalID); document.getElementById('projectName').focus(); document.getElementById('projectForm').addEventListener('submit', function(e) { e.preventDefault(); const projectName = document.getElementById('projectName').value.trim(); const projectFormat = document.querySelector('input[name="projectFormat"]:checked').value; // const projectFormat = document.getElementById('projectFormat').value; IF YOU WANT TO USE DROPDOWN INSTEAD OF RADIO BUTTONS if (!isValid(projectName)) { alert("Please enter a valid folder name, no special characters."); return; } if (projectName != null && projectName != "") { post('', { foldername: projectName, format: projectFormat }, 'post'); } }); document.getElementById('cancelForm').addEventListener('click', function() { ModalOff(ModalID); }); } // addshow async function AJAXGET(URL) { // Sends a GET request to server. // Async keyword added in 1.0.15. // require ..... URL (such as '/CCG/testfunction/' ) // returns ..... true/false based on HTTP status codes, such as // 200 OK // 409 Conflict var xmlhttp = new XMLHttpRequest(); xmlhttp.onreadystatechange = function () { if (this.readyState == 4 && this.status == 200) { if (this.responseText) { // console.log('AJAX completed succesfully: ' + this.responseText) return true } } if (this.readyState == 4 && this.status != 200) { console.log('Ajax error, status: ' + this.status + ', state: ' + this.readyState); return false } }; xmlhttp.open("GET", URL); xmlhttp.send(); } // AJAXGET ended function applyTextEditChanges(event, e) { // end editing a TG field setMasterButtonStates(e.closest('.itemrow')); if (APPSTATE != "EDITING") { return; } if (event.keyCode == 13 || event.which == 13) { // 13 = enter key ToggleExpand(); } } // applyTextEditChanges function AppState(NewState) { // ********************************************************************** // // Options for global application state. This can be utilized // by several function around the application. You can use APPSTATE // statement in devtools to see the currently active state... // // Usage: AppState('DEFAULT'); // // AppState('DEFAULT'); // ......... nothing special // AppState('INIT'); // ......... initialization of the page // AppState('EDITING'); // ......... editing a text field (allow spaces) // // console.log('Old state: ' + APPSTATE, 'New state: ' + NewState); // disable sorting while editing: if (NewState == 'EDITING') { if ( ife('identifier').value=="controller" ) { sortable.option("disabled", true); } } else { if ( ife('identifier').value=="controller" ) { sortable.option("disabled", false); } } APPSTATE = NewState; // // ********************************************************************** } // AppState function CancelOutTimerIfRunning(CURITEM){ // if item has a running timeout attribute, then stop and clear it let TimerID = CURITEM.getAttribute('data-spx-timerid') || ''; if (TimerID){ CURITEM.setAttribute('data-spx-timerid',''); clearTimeout(TimerID); CURITEM.querySelector('[data-spx-name="icon"]').classList.remove('playAuto'); // Improved in v1.3.1 let smallIcon = document.getElementById('deleteSmall' + CURITEM.getAttribute('data-spx-index')); let largeIcon = document.getElementById('deleteLarge' + CURITEM.getAttribute('data-spx-index')); if (smallIcon) { smallIcon.style.display="inline-block" } if (largeIcon) { largeIcon.style.display="block" } } } // CancelOutTimerIfRunning function cas() { // Opens the selected (or current) file in controller. // Function works both in Edit and Lists -views. // require ..... nothing // returns ..... reloads the browser to another URL let filename = ""; let element = ""; let folder = document.getElementById("hidden_folder").value; element = document.getElementById("lists"); if (typeof (element) != 'undefined' && element != null) { filename = element.value; } element = document.getElementById("filebasename"); if (typeof (element) != 'undefined' && element != null) { filename = element.value; } if (!filename) {return} document.location = '/gc/' + folder + "/" + filename; } // cas ended function cfg() { // Show settings // Called from Settings button in episodes page. let foldname = document.getElementById("hidden_folder").value; document.location = '/show/' + foldname + "/config"; } // del ended function checkAllConnections() { // require ..... nothing // returns ..... forces server to check for CCG connections and pushes updates to indicators via socket.io function aFunctionTest() { // execute a test function on the server AJAXGET('/CCG/testfunction/'); } } //checkAllConnections function checkKey(e) { // require ..... keyboard event // returns ..... executes keyboard shortcuts, such as applies "Return" to a text field while editing. // keybord shortcuts (see https://keycode.info/) // TODO: Add your desired keyboard shortcut functions here. e = e || window.event; // console.log('--- ' + e.keyCode + ' --- ' + APPSTATE); // FIRST GENERIC keycodes for all situations switch (e.keyCode) { case 116: // F5 updateItem(); e.preventDefault(); // do not refresh browser return; break; case 112: // F1 if (document.getElementById('overlayHelp')){ ModalOn('overlayHelp'); // only valid in Controller view. e.preventDefault(); // do not open built-in help } return; break; case 27: // esc if (document.getElementById('filebasename')) { // we are in controller stopAll(); // STOP all layers used by current rundown } case 68: // d and D if (e.ctrlKey && document.getElementById('filebasename')) { // Ctrl+D and we are in controller duplicateRundownItem(); e.preventDefault(); // do use add to bookmarks } return; break; } // THEN APPSTATE dependent keycodes switch (APPSTATE) { // EDITING (while editor open) case "EDITING": // console.log('Captured keypress while editing'); switch (e.keyCode) { case 13: // enter if ( document.activeElement.nodeName === "TEXTAREA" && document.hasFocus()) { // console.log('Come on, we are multilining here'); return; } saveTemplateItemChangesByElement(getFocusedRow()); e.preventDefault(); break; default: // any other key if (document.querySelector('.inFocus')) { document.querySelector('.inFocus').setAttribute("data-spx-changed","true"); } break; } return; break; case "INIT": return; break; // OTHER MODES, (while editor closed) default: // console.log('Captured keypress while NOT editing'); switch (e.keyCode) { case 13: // enter ToggleExpand() e.preventDefault(); break; case 32: // space if (e.shiftKey) { // Shift + Space = PLAY/STOP playItem(); } else { let item = getFocusedRow(); // Space = PLAY/CONTINUE/STOP // Bug fix added in 1.1.1. Dblcheck if we are opened... if ( item.querySelector('#Expanded')?.style .display != 'none' ) { return; } // Added in v1.4.0 space works as PLAY/CONTINUE/STOP. var stepsleft = parseInt(item?.getAttribute('data-spx-stepsleft')); // Check if stepsleft isNan (not defined in templateDefinition) = PLAY/STOP, if == 1 (last step) = PLAY/STOP, if > 1 and not playing = PLAY if (isNaN(stepsleft) || stepsleft == 1 || (stepsleft > 1 && item.getAttribute('data-spx-onair') != 'true') || (stepsleft == -1 && item.getAttribute('data-spx-onair') != 'true') ) { playItem(); } // Check if the item has steps, if so, continue to next step else if (stepsleft > 1 || stepsleft == -1) { nextItem(); } } e.preventDefault(); break; case 38: // up moveFocus(-1); break; case 40: // down moveFocus(1); break; case 116: // F5 updateItem(); e.preventDefault(); // do not refresh browser break; case 20: // CapsLock if (e.getModifierState("CapsLock")) { showItemIDs(true); } else { showItemIDs(false); } break; } break; } // setTimeout(function(){ // setMasterButtonStates(document.querySelector('.inFocus').getAttribute('data-spx-index'),'from checkKey'); // }, 20); return; } //checkKey function clearAttributes(attName, attValue) { // This is called from TGbuttonAction when TG Play button is // pressed. This clears all found attName/attValues which // forces the play/stop panel to show up upon focus. // require ..... attribute name ("data-name") and value ("jack") to search for clearing // returns ..... removes green arrow from TG list DOM let TGrows = document.querySelectorAll('.itemrow'); TGrows.forEach(function (item) { if (item.getAttribute(attName) == attValue) { item.setAttribute(attName, ''); item.querySelector("#dragIcon").innerHTML = "☰"; // drag icon item.querySelector("#dragIcon").classList.remove("green"); } }) } // clearAttributes ended function copyItemID(itemID, api='') { let currentHost = location.protocol+'//'+location.hostname+(location.port ? ':'+location.port: ''); let str = itemID; let inf = 'itemID'; switch (api.toUpperCase()) { case 'PLAY': str = currentHost + '/api/v1/item/play/' + itemID; inf = 'API play command' break; case 'STOP': str = currentHost + '/api/v1/item/stop/' + itemID; inf = 'API stop command' break; case 'CONTINUE': str = currentHost + '/api/v1/item/continue/' + itemID; inf = 'API continue command' break; default: break; } copyText(str); showMessageSlider('Copied ' + inf + ' to clipboard', 'happy'); showItemIDs(false) } // copyItemID // function toggleLRendererHandler(slider, targ) { // let Cfgdata = {}; // Cfgdata.key = 'disableLocalRenderer' // if (slider.checked) { // document.getElementById(targ).innerText = 'YES'; // document.getElementById('previewIF').src = '/templates/empty.html'; // document.getElementById('LocalRendererDisabledNotification').style.opacity = 1; // Cfgdata.val = true; // } else { // document.getElementById('previewIF').src = '/renderer'; // document.getElementById(targ).innerText = 'NO'; // document.getElementById('LocalRendererDisabledNotification').style.opacity = 0; // Cfgdata.val = false; // } // ajaxpost('/gc/saveConfigChanges',Cfgdata); // } function toggleSwitchHandler(slider, targ, type) { // When program/preview toggle buttons are clicked. // console.log('toggleSwitchHandler', slider, targ); let data = {} data.type = type; let handle = slider.querySelector('.vc-handle'); if (slider.checked) { data.command = 'open'; document.getElementById(targ).innerText = 'ON'; } else { data.command = 'close'; document.getElementById(targ).innerText = 'OFF'; } handleRendererPopups(data) } // toggleSwitchHandler function toggleRundownSettings() { // Toggle the rundown settings panel let optionsZone = document.getElementById('rdOptionsCollapsible'); if (optionsZone.style.maxHeight){ optionsZone.style.maxHeight = null; setTimeout(function(){optionsZone.style.display = 'none';}, 300); } else { optionsZone.style.display = 'block'; optionsZone.style.maxHeight = optionsZone.scrollHeight + "px"; } } // toggleRundownSettings function toggleTip(targetField, source, attr) { document.getElementById(targetField).innerText = source.getAttribute(attr); } // toggleTip function toggleActive(ServerName='') { // Will enable / disable commands to the server // CCG/disable {server:'OVERLAY, disable:false} let e = document.getElementById('server'+ ServerName); let targetState = false; if (e && e.classList.contains('disabledServer')) { // Enable e.classList.remove('disabledServer') targetState = false } else { // Disable e.classList.add('disabledServer') targetState = true } let data = {}; data.server = ServerName; data.disabled = targetState; working('Changing server connection state to [' + targetState + ']...'); ajaxpost('/CCG/disable',data); } function clearUsedChannels(ServerName='') { // Will call a command over API which will clear passed // server or all by default. let data = {}; data.server = ServerName; data.foldername = document.getElementById('foldername').value; data.datafile = document.getElementById('datafile').value; working('Clearing ALL graphic channels used by program [' + data.foldername + ']...'); ajaxpost('/gc/clearPlayouts',data); } //clearUsedChannels function closePromo() { // does just that and stores info to the localStorage so // the same promo will not be show... let e = document.getElementById('promo'); localStorage.setItem('SPX_ClosedPromo', e.getAttribute('data-promo-name')); e.style.display = 'none'; } function CollectJSONFromDOM() { // iterate DOM and collect data to an array of objects // This is called by SaveNewSortOrder(). // console.log('Collecting JSON'); let JSONdata = []; let Items = document.querySelectorAll('.itemrow'); Items.forEach(function (item) { var dataname = item.getElementsByTagName("input")[0].value; var datatitl = item.getElementsByTagName("input")[1].value; var Item = {}; Item.f0 = dataname; Item.f1 = datatitl; JSONdata.push(Item) }) // console.log(JSONdata); return JSONdata; } // CollectJSONFromDOM ended function continueUpdateStop(command, itemrow='') { // request ..... command, itemrow (optional) // returns ..... ajax response // This is being called (at least) from custom-functions... if (!itemrow) { itemrow = getFocusedRow(); } switch (command) { case 'stop': // console.log('Stopping item', itemrow); playItem(itemrow, 'stop'); break; case 'continue': console.log('------continue not implemented-----'); break; case 'update': console.log('------update not implemented-----'); break; default: console.log('Unknown command ['+ command +']'); break; } } // ContinueUpdateStop() ended async function revealItemID(button) { let item = button.closest('.itemrow') let ID = item.getAttribute('data-spx-epoch'); copyText(ID) // alert("Item ID " + ID + " was copied to clipboard."); var newID = prompt("Item ID " + ID + " was copied to clipboard.\nYou can change the ID here:", ID); if (newID != null && newID != ID) { // alert("New ID " + newID) let file = document.getElementById('datafile').value; let URL = `/api/v1/changeItemID?rundownfile=${file}&ID=${ID}&newID=${newID}` let IDchanged axios.get(URL) .then(function (response) { showMessageSlider('Item renamed to ' + newID + '. Reload recommended.') item.setAttribute('data-spx-epoch', newID); item.querySelector('.copyid').setAttribute('onmouseover', `tip('Copy ID ${newID}')`); }) .catch(function (error) { showMessageSlider('Unable to rename. Use unique ID and try again.', 'error') }); // if (IDchanged) { // // success // item.setAttribute('data-spx-epoch', newID); // } else { // alert("Unable to change ID to " + newID + " (" + IDchanged + ").\nMake sure the ID is unique and try again."); // } } else { // alert("Same ID " + newID) } } // revealItemID async function revealItemTiming(button) { let curValue = button.textContent; let ID = button.closest('.itemrow').getAttribute('data-spx-epoch'); var newValue = prompt("You can change timing here. Use 'manual', 'none' or millisecond values (1000=1s):", curValue); if (newValue != null && newValue != curValue) { let file = document.getElementById('datafile').value; let URL = `/api/v1/changeItemData?rundownfile=${file}&ID=${ID}&key=out&newValue=${newValue}` axios.get(URL) .then(function (response) { showMessageSlider('Item out mode changed to ' + newValue) button.textContent = newValue; button.closest('.itemrow').querySelectorAll('input[id^=out]')[0].value = newValue; }) .catch(function (error) { showMessageSlider('Unable to change value, please try again.', 'error') }); } } // revealItemTiming async function revealItemLayer(button) { let curValue = button.textContent; let ID = button.closest('.itemrow').getAttribute('data-spx-epoch'); var newValue = prompt("You can change web playout layer here. Use any value between 1 and 5:", curValue); if (newValue != null && newValue != curValue && !isNaN(newValue)) { let wasOverLimit = newValue > 5; newValue = Math.min(newValue, 5); // enforce max limit of 5 let file = document.getElementById('datafile').value; let URL = `/api/v1/changeItemData?rundownfile=${file}&ID=${ID}&key=webplayout&newValue=${newValue}` axios.get(URL) .then(function (response) { if (wasOverLimit) { showMessageSlider('Item webplay layer must be 5 or less, changed to 5 ', "warn") } else { showMessageSlider('Item webplay layer changed to ' + newValue) } button.textContent = newValue; button.closest('.itemrow').querySelectorAll('input[id^=webplayout]')[0].value = newValue; // document.querySelectorAll('.itemrow')[0].querySelectorAll('input[id^=out]')[0]; }) .catch(function (error) { showMessageSlider('Unable to change value, please try again.', 'error') }); } else { showMessageSlider('Unable to change value, please try again.', 'error') } } // revealItemLayer function copyRendererUrl(preview=false) { let page = '/renderer/'; if (preview) { page = '/renderer?preview=true'; } var RendererUrl = location.protocol+'//'+location.hostname+(location.port ? ':'+location.port: '') + page; copyText(RendererUrl); showMessageSlider('Copied URL to clipboard', 'happy'); } // copyRendererUrl ended function copyText(txt) { const temp = document.createElement('input'); document.body.appendChild(temp); temp.value = txt; temp.select(); try { var successful = document.execCommand('copy'); var msg = successful ? 'successful' : 'unsuccessful'; // console.log('Copy command was ' + msg); } catch (err) { // console.log('Oops, unable to copy'); } temp.parentNode.removeChild(temp); } // copyText ended function heartbeat(dd, getOnly = false) { // 36 24 36 hey // see notes TDT=00d86114 (/..es2/heartbeat) let nw, st, key, val, dif; let fD = '|'; let vD = ':'; let eK = 'TS' let ls = 'SPXGC_UI_stats'; let or = localStorage.getItem(ls) || eK + vD + "0" + fD + dd + vD + "0"; if (getOnly) { return or; } let ar = or.split(fD); let ok = false; let rp = false; ar.every(function(f,idx) { key = f.split(vD)[0]; val = f.split(vD)[1]; if (key==eK) { dif = Math.round((Date.now()-val)/(1000*60*60*24)); if (Math.round((Date.now()-val)/(1000*60*60*24))>0) { rp = true; }; // ################### // rp = true; // debug // ################### ar[idx]=eK + vD + Date.now(); } if (key==dd) { nw = parseInt(val)+1; ar[idx]=key + vD + nw; ok = true; return false } else { ok = false; return true } }); if ( !ok ) { ar.push(dd + vD + '1'); } st = ar.join(fD); if (rp) { // FIXME: // console.log('Submit ' + or) getMessages2(or) st = eK + vD + Date.now() + fD + dd + vD + 1; } else { // FIXME: // console.log('Resume ' + st) } localStorage.setItem(ls, st) } // dirty deeds done. function del() { // Episodes -view // Delete a datafile from disk. // Called from delete button in lists page. var filename = document.getElementById('lists').value; if (!filename) {return} let foldname = document.getElementById("hidden_folder").value; if (confirm('SURE?! Delete "' + filename + '" and all it\'s data?')) { fetch('/show/' + foldname + "/" + filename, { method: 'DELETE' }); setTimeout(function(){ document.location = '/show/' + foldname; }, 500); } } // del ended function delshow() { // Shows -view // Delete a folder from disk. // Called from delete button in shows page. // setTimeot - since we do it the AJAX style. var foldername = document.getElementById('lists').value; if (foldername==""){return}; if (confirm('SURE?! Delete "' + foldername + '" and all it\'s data?')) { fetch('/shows/' + foldername, { method: 'DELETE' }); setTimeout(function(){ document.location = '/shows'; }, 500); } } // delshow ended function delRow(e) { // console.log('Would delete', e); if (!e){return}; document.getElementById('itemList').removeChild(e); // save data SaveNewSortOrder(); } // delRow ended function duplicateRundown() { // Duplicate a rundown data={}; let itemName = document.getElementById('lists').value; if (!itemName) {return} data.filename=itemName + '.json'; data.foldname=document.getElementById("hidden_folder").value; ajaxpost('/gc/duplicateRundown',data); setTimeout(function(){ document.location = '/show/' + data.foldname; }, 500); } // duplicateRundown ended function duplicateRundownItem(rowitem) { if (!rowitem){ rowitem = getFocusedRow(); } // Collect data for the server to duplicating the item data={}; data.command = "duplicateRundownItem"; data.sourceEpoch = rowitem.getAttribute('data-spx-epoch'); data.cloneEpoch = String(Date.now()); // create epoch and give this to server also data.foldername = document.getElementById('foldername').value; data.listname = document.getElementById('filebasename').value; data.datafile = document.getElementById('datafile').value; var newItem = rowitem.cloneNode(true); newItem.style.opacity=0; // update table id's for sorting to keep up. A bug found right after 1.0.12 was released. updateFormIndexes() rowitem.after(newItem); let list = [ newItem.setAttribute('data-spx-epoch', data.cloneEpoch), newItem.setAttribute('data-spx-onair', "false"), newItem.querySelector('.copyid').setAttribute('onmouseover', 'tip("Duplicated item ' + data.cloneEpoch + '")'), newItem.querySelector('.utilityOverlay').innerHTML="Reload page to update this duplicated item.", newItem.querySelector('[data-spx-name="icon"]').classList.add('playFalse'), newItem.querySelector('[data-spx-name="playbutton"]').classList.add('bg_green'), newItem.querySelector('[data-spx-name="icon"]').classList.remove('playTrue'), newItem.querySelector('[data-spx-name="playbutton"]').classList.remove('bg_red'), newItem.querySelector('[data-spx-name="playbutton"]').textContent = newItem.querySelector('[data-spx-name="playbutton"]').getAttribute('data-spx-playtext'), rowitem.classList.remove('inFocus'), newItem.classList.add('inFocus'), newItem.style.opacity=1 ] applyCommands(list); working('Sending ' + data.command + ' request.'); ajaxpost('',data); function applyCommands(list) { // delay commands utility let totalDelay = 0; let stepDelay = 10; list.forEach((item,index) => { totalDelay += stepDelay; setTimeout(function () { (item) }, totalDelay); }); } } // duplicateRundownItem ended function edi() { // Edit a datafile on disk. // Called from edit button in lists page. var filename = document.getElementById('lists').value; document.location = '/shows/' + filename; } // edi ended function openRelpathFolder(itemrow) { // added in 1.1.1 - Open a file for editing. let fileRef = itemrow.querySelector("[id^='relpath']").value; AJAXGET('/api/openFileFolder?file=' + fileRef); } // openRelpathFolder ended function openLightModePanel() { // Open a light mode panel (in Controller only) let newUrl = window.location.href + '/light'; window.open(newUrl, 'SPX-LITE', '_blank, width=680, height=850, scrollbars=yes, location=yes,status=yes'); } function eps() { // Opens the selected (or current) file in controller. // Function works both in Edit and Lists -views. // require ..... nothing // returns ..... reloads the browser to another URL let foldername = ""; let element = ""; element = document.getElementById("lists"); if (typeof (element) != 'undefined' && element != null) { foldername = element.value; } element = document.getElementById("filebasename"); if (typeof (element) != 'undefined' && element != null) { foldername = element.value; } if (foldername==""){return}; document.location = '/show/' + foldername; } // cas ended function exportItemAsCSV(rowItem) { // export an item as a CSV file, which then can be // filled in and imported back, so it will auto-generate // a bunch of graphics. // Added in 1.0.15 let data = {}; data.foldername = document.getElementById('foldername').value; data.datafile = document.getElementById('filebasename').value; data.itemID = rowItem.getAttribute('data-spx-epoch'); working('Generating CSV file to ASSETS/csv -folder.'); ajaxpost('/api/exportCSVfile',data); showMessageSlider('CSV file generated to ASSETS/csv -folder.') AJAXGET('/api/openFileFolder?openFolderOnly=csv'); } // exportItemAsCSV ended function ModalOn(modalID) { // Added in 1.1.4 - reset selections var allItems = document.getElementsByClassName("filebrowser_file") || []; Array.prototype.forEach.call(allItems, function(el) { el.classList.remove('selectedFile') }); if (document.getElementById('allToggle')) { document.getElementById('allToggle').innerText=document.getElementById('allToggle').getAttribute('data-all-label'); } document.getElementById(modalID).style.display = "block"; } //FileBrowserOn function ModalOff(modalID) { document.getElementById(modalID).style.display = "none"; } //FileBrowserOff function openImportDialog() { ModalOff('overlayTemplatePicker') ModalOn('filebrowserModal') // console.log('Opening File browser dialog'); } // openImportDialog function filterProjects() { // Added in 1.1.0 - will hide/show items in the allProjects list var input, filterPhrase, projList, li, itemValue, i, txtValue; input = document.getElementById('projectFilter'); filterPhrase = input.value.toUpperCase(); projList = document.getElementById("allProjects"); optionItems = projList.getElementsByTagName('option'); lists.innerHTML=''; // clear for (i = 0; i < optionItems.length; i++) { txtValue = optionItems[i].innerText; let filters = filterPhrase.split(" ") filters = filters.filter(f => f.length) let shouldDisplay = true filters.forEach(filterItem => { shouldDisplay = shouldDisplay && txtValue.toUpperCase().includes(filterItem) }) if (shouldDisplay || filters.length === 0) { var opt = document.createElement('option'); opt.innerHTML = txtValue; lists.appendChild(opt); } } } // filterProjects ended function focusRow(rowitemOrIndex) { // will make TG item focused and will set masterbutton states. // console.log('Focusing', typeof rowitemOrIndex, rowitemOrIndex); // clear focus from all rows first document.querySelectorAll('.itemrow').forEach(function (item, i) { if (item.classList.contains("inFocus")) { item.classList.remove('inFocus'); } else { item.setAttribute('data-spx-previewing', 'false'); // added in 1.1.1 } }) var index = 0; // for master buttons let TargetElement = ''; if (typeof rowitemOrIndex == "number") { // console.log('rowitemOrIndex was a number'); TargetElement = document.querySelectorAll('.itemrow')[rowitemOrIndex]; index = rowitemOrIndex; } if (typeof rowitemOrIndex == "object") { TargetElement = rowitemOrIndex; index = getIndexOfRowItem(rowitemOrIndex); } if (TargetElement){ TargetElement.classList.add('inFocus'); TargetElement.scrollIntoView({ behavior: "auto", block: "nearest", }); // Added in 1.1.0: if (document.getElementById('previewMode').value==='selected') { if (TargetElement.getAttribute('data-spx-previewing')!='true') { TargetElement.setAttribute('data-spx-previewing', 'true'); // added in 1.1.1 to avoid double previewing previewItem(); // preview selected item } } } setMasterButtonStates(TargetElement, 'from focusRow'); } // focusRow ended function getFocusedRow() { // a utility to iterate DOM rows, returns focused element reference return document.querySelectorAll('.inFocus')[0]; } // getFocusedRow ended function getElementByEpoch(itemID) { // get element by epoch id (added in 1.0.8) let DomItemID; for (let index = 0; index < document.querySelectorAll('.itemrow').length; index++) { DomItemID = document.querySelectorAll('.itemrow')[index].getAttribute('data-spx-epoch'); if (DomItemID==itemID) { return document.querySelectorAll('.itemrow')[index] }; } return false; // added in 1.2.0 } // getElementByEpoch ended function getIndexOfRowItem(rowitem) { // Utility, gets a row element and returns it's index document.querySelectorAll('.itemrow').forEach(function (item, i) { if (rowitem = item) { return i; } }) } // getIndexOfRowItem ended async function appendSpxApiKey (data) { // Added in v1.3.2 // Adds API key to data object or a URL // from localstorage or a URL parameter // Get apikey from URL queryString = window.location.search; urlParams = new URLSearchParams(queryString); addressKey = urlParams.get('apikey') || null; // Get apikey from local storage let storageKey = localStorage.getItem('spxApiKey') || null; if (!addressKey && !storageKey) { // No API key found, return original data return data; } let apikey = storageKey; // default to storage key if (addressKey) { apikey = addressKey; // use address key if present } if (typeof data === 'object') { // POST - so add API key to data data.apikey = apikey; return data; // return object with API key } else { // GET - so add API key to URL let char = data.includes('?') ? '&' : '?'; data += char + 'apikey=' + apikey; return data; // return URL with API key } } // appendSpxApiKey function getDomIndexByElementId(ElementId) { // A utility to iterate all DOM items and return dom index of given ElementId // request = ElementID // returns = dom index of the given ElementID for (let DomNro = 0; DomNro < document.querySelectorAll('.itemrow').length; DomNro++) { if (document.querySelectorAll('.itemrow')[DomNro].id=='playlistitem' + String(ElementId)) { // console.log('Found DomIndex ' + DomNro + ' for ElementID ' + String(ElementId) + '.'); return DomNro }; } } // getDomIndexByElementId ended function getLayerFromProfile(buttonName) { // Make JSON from profiledata string from a hidden field and search it... // console.log('Searching layer for ' + buttonName); // This is a utility which is called bu Vue when checking button states. let profiledata = JSON.parse(document.getElementById('profiledata').value); let prfName = document.getElementById('profname').innerText.toUpperCase(); for (var i = 0; i < profiledata.profiles.length; i++) { let curName = profiledata.profiles[i].name.toUpperCase(); if (curName == prfName) { let SRV = profiledata.profiles[i].templates[buttonName].server; let CHA = profiledata.profiles[i].templates[buttonName].channel; let LAY = profiledata.profiles[i].templates[buttonName].layer; let CCG = SRV + " " + CHA + " " + LAY; // console.log("Render layer of [" + buttonName + "] in profile [" + prfName + "] is [" + CCG + "]."); return CCG; } } } // getLayerFromProfile ended function getMessages(curVerInfo) { // Poll server for upgrade info. ------------------------------ // Requires CORS in the route. // Moved from view-home. // // PLEASE NOTE: This will be replaced with improved mechanism // which will report usage stats also and will // notify user with a small icon to reveal the // promo within controller UI. // This will happen in 1.1.x. // // All passed data is anonymous and non-identifiable // ------------------------------------------------------------- let versionStr = curVerInfo.split('v=')[1].split('&')[0].trim(); // console.log('---------- getMessages [' + versionStr + ']', curVerInfo); localStorage.removeItem('SPX-GC-NewVersion'); document.getElementById('upgradeinfo').style.display="none"; document.getElementById('messageinfo').style.display="none"; var url = (`https://www.smartpx.fi/gc/messageservice/?` + curVerInfo ); fetch(url) .then((res) => res.json()) .then((messages) => { let currntVer = document.getElementById('footerVerDisplay').innerText; let latestVer = messages.latest.vers; let dbggreet = messages.dbggreet || ""; // this has been broken until 1.0.11 let debugNotifications = false; if ( document.getElementById('greeting') && document.getElementById('greeting').innerText!='' ) { if ( dbggreet == document.getElementById('greeting').innerText ) { // if config.greeting == message.greeting then debug it! debugNotifications = true; } } if (messages.latest.vers && versInt(latestVer) > versInt(currntVer) || debugNotifications ) { // Yes, there is new version available localStorage.setItem('SPX-GC-NewVersion',latestVer); document.getElementById('upgradeinfo').style.display="block"; document.getElementById('upgrade_date').innerText=messages.latest.date; document.getElementById('upgrade_head').innerText=messages.latest.head; document.getElementById('upgrade_body').innerText=messages.latest.body; document.getElementById('upgrade_call').innerText=messages.latest.call; document.getElementById('upgrade_link').href=messages.latest.href; document.getElementById('upgrade_link').innerText=messages.latest.link; } if (messages.notification.type && messages.notification.type!="none" || debugNotifications ){ // Yes, there is something else to announce document.getElementById('messageinfo').style.display="block"; document.getElementById('messageinfo').classList="message-" + messages.notification.type; document.getElementById('messagelink').classList="message-" + messages.notification.type; document.getElementById('message_date').innerText=messages.notification.date; document.getElementById('message_head').innerText=messages.notification.head; document.getElementById('message_body').innerText=messages.notification.body; document.getElementById('message_call').innerText=messages.notification.call; document.getElementById('message_link').href=messages.notification.href; document.getElementById('message_link').innerText=messages.notification.link; } return // Check if there is a promo for this version // Added in 1.1.2 // versionStr = "1.0.0"; // DEBUG## with specific version number if (messages.homepagepromo[versionStr] ) { // console.log('Promo available for v.' + versionStr, messages.homepagepromo[versionStr]); document.getElementById('homepagepromolink').href=messages.homepagepromo[versionStr].link; document.getElementById('homepagepromopict').src=messages.homepagepromo[versionStr].pict; document.getElementById('homepagepromopict').title=messages.homepagepromo[versionStr].titl; } else { // Check if there is a default promo if (messages.homepagepromo && messages.homepagepromo.default && messages.homepagepromo.default.active ){ // console.log('Using active default promo.', messages.homepagepromo.default); document.getElementById('homepagepromolink').href=messages.homepagepromo.default.link; document.getElementById('homepagepromopict').src=messages.homepagepromo.default.pict; document.getElementById('homepagepromopict').title=messages.homepagepromo.default.titl; } } }) .catch((error) => { console.error('SPX error in getMessages().',error) }); } // getMessages ended function getMessages2(or) { // This is called from heartbeat after a day change and will // post anonymous usage data and may get potential notification // data back to be displayed in the SPX user interface. // This function is in early stage in v 1.1.0 and will be // fully implemented in future versions. ajaxpost('/api/heartbeat', {data:or}); } // getMessages2 ended function handleRendererPopups(data) { // open / close renderer popups // data: {type:preview} // Save to config.json general.renderer as "normal" (embedded inline renderer) or "popup" (floating window) let URL; let socketData = {}; var w = 1280; var h = 720; var left = (screen.width/2)-(w/2); var top = (screen.height/2)-(h/2); var OPT = '_blank,toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width='+w+', height='+h+', top='+top+', left='+left; if (data.type==='preview') { URL = '/renderwindow/preview'; winRef = 'SPXpreviewWindow'} if (data.type==='program') { URL = '/renderwindow/program'; winRef = 'SPXprogramWindow'} if (data.command == 'open') { // open the popup window.open(URL, winRef, OPT); } if (data.command == 'close') { // send message request to close the popup // send message to close the window without prompts (closeprogram closepreview) socketData.spxcmd = 'close' + data.type; socket.emit('SPXWebRendererMessage', socketData); } if (data.type == 'program') { toggleNormalRenderer(data.command) } } // handleRendererPopups ended function help(section) { // Help is massively a WORK-IN-PROGRESS thing! // Wanna help? Let us know. // // Open help feature. This is a central location to // handle help links. The 'section' argument is optional // and can redirect to desired pages/bookmarks... // // request ..... section or // returns ..... opens a tab with help content let HELP_ROOT = "https://docs.spxgraphics.com/" let HELP_PAGE = "" section = section.toUpperCase() switch (section) { case 'INTRO': HELP_PAGE = 'Welcome+to+SPX+Docs'; break; case 'PROJECTS': HELP_PAGE = 'Documentation/Server/Content+Management#Projects'; break; case 'PLAYLISTS': HELP_PAGE = 'Documentation/Server/Content+Management#Rundowns'; break; case 'CONFIG': HELP_PAGE = 'Documentation/Graphics+Controller/Application+Configuration'; break; case 'WEBPLAYOUT': HELP_PAGE = 'Documentation/Renderer/Overview'; break; case 'CASPARCG': HELP_PAGE = 'Documentation/Renderer/Workflows/CasparCG+-+AMCP'; break; case 'GLOBALEXTRAS': HELP_PAGE = 'Documentation/Graphics+Controller/Plugins+%26+Extensions#Global+Extras+vs+Plugins'; break; case 'PROJECTGENERAL': HELP_PAGE = 'Documentation/Graphics+Controller/Project+Settings#Template+Configuration'; break; case 'PROJECTTEMPLATES': HELP_PAGE = 'Documentation/Graphics+Controller/Project+Settings'; break; case 'PROJECTVARIABLES': HELP_PAGE = 'article/help-project-variables'; break; case 'PROJECTEXTRAS': HELP_PAGE = 'Documentation/Graphics+Controller/Project+Settings#Project+Extras+(showExtras)'; break; case 'CONTROLLER': HELP_PAGE = 'Documentation/Control+Interfaces/External+Control'; break; case 'CSV': HELP_PAGE = 'Guides/Tutorials/how+to+use+csv+files'; break; case 'API': HELP_PAGE = 'Documentation/Control+Interfaces/REST/Overview+of+SPX+API'; break; case 'ITEM-DETAILS': HELP_PAGE = 'Documentation/Graphics+Controller/Overview#Playout+details'; break; case 'SEVERAL': HELP_PAGE = 'Documentation/Graphics+Controller/Overview#Several+SPX+controllers'; break; case 'LIGHTMODE': HELP_PAGE = 'article/light-mode'; break; case 'MOS': HELP_PAGE = 'Documentation/Server/Integration/MOS'; break; case 'OLDBROWSERDETECTED': HELP_PAGE = 'article/old-browser-detected'; break; case 'LICENSING': HELP_PAGE = 'Documentation/Server/License'; break; case 'CALLUPID': HELP_PAGE = 'article/callupid'; break; case 'THUMBNAILGENERATION': HELP_PAGE = 'article/thumbnail-generation'; break; case 'OGRAF': HELP_PAGE = 'Documentation/Graphic+Templates/Formats/OGraf'; break; default: HELP_PAGE = 'Welcome+to+SPX+Docs/'; break; } let FULL_URL = HELP_ROOT + HELP_PAGE; window.open(FULL_URL, 'GCHELP', '_blank,width=980,height=800,scrollbars=yes,location=yes,status=yes'); } // help ended function moveFocus(nro) { // Move highlighted row up/down with keyboard commands // Request .... nro = negative (-1) or positive (1) // Returns .... nothing, just set focus let itemCount = document.querySelectorAll('.itemrow').length; let curIndex=0; for (let item = 0; item < itemCount; item++) { if (document.querySelectorAll('.itemrow')[item].classList.contains('inFocus')) { curIndex=item; } } if (nro > 0 ){ // positive, move downwards newIndex=curIndex+1; //if (newIndex>itemCount-1){newIndex=0}; // go back to top if (newIndex>itemCount-1){newIndex=itemCount-1}; // stay at the bottom focusRow(newIndex); } if (nro < 0 ){ // negative, move upwards newIndex=curIndex-1; //if (newIndex<0){newIndex=itemCount-1}; // go to end of list if (newIndex<0){newIndex=0}; // stay at the top focusRow(newIndex); } } // moveFocus ended function nextItem(itemrow='') { // Next / continue command. if (!itemrow) { // itemrow not given, get focused row itemrow = getFocusedRow(); } data = {}; data.datafile = document.getElementById('datafile').value; data.epoch = itemrow.getAttribute('data-spx-epoch'); // decrease steps left var stepsleft= parseInt(itemrow.getAttribute('data-spx-stepsleft'))-1; itemrow.setAttribute('data-spx-stepsleft',stepsleft); if (stepsleft < 1) { // console.log('No more steps (' + stepsleft + ') left, stopping item.'); // This should go somewhere else - because of DRY. // This duplicates some code from playItem itemrow.setAttribute('data-spx-onair', false); itemrow.setAttribute('data-spx-stepsleft',itemrow.querySelector('input[name="RundownItem[steps]"]').value); // reset counter let uselessReturnValue = setItemButtonStates(itemrow, forcedCommand='stop'); document.getElementById('MasterCONTINUE').classList.add('disabled'); data.command = 'stop'; setMasterButtonStates(itemrow); } else { // console.log('Steps left: ' + stepsleft + ', continuing item...'); data.command = 'next'; } working('Sending ' + data.command + ' request.'); ajaxpost('/gc/playout',data); //### DEBUG return; // Check for function_onNext event (added in 1.1.4): let onNextField = itemrow.querySelector('[name="RundownItem[function_onNext]"]'); // console.log('onNextField: ', data.command, onNextField); if (onNextField && onNextField.value!="") { // console.log('onNextField: ' + onNextField.value); let onNextCommand = onNextField.value.split('|') || []; // console.log('[' + String(new Date().toLocaleTimeString()) + '] ' + 'NEXT event: ' + onNextCommand); let FunctionName = String(onNextCommand[0] || 'noFunctionName').trim(); let FunctionArgs = String(onNextCommand[1] || 'NoArgs').trim(); let ExecuteDelay = String(onNextCommand[2] || '100').trim(); let ConditFField = String(onNextCommand[3] || 'XXX').trim(); let conditionVal = null; let eventCondtFF = itemrow.querySelector('[data-update=' + ConditFField + ']'); if (eventCondtFF) { conditionVal = eventCondtFF.value; } setTimeout(function () { // console.log('FunctionName:' + FunctionName); window[FunctionName]([FunctionArgs, conditionVal, data.epoch]); }, parseInt(ExecuteDelay)); heartbeat(314); // identifier } } // nextItem function previewItem(itemrow='') { // Added in 1.1.0 // console.log('Itemrow: ', itemrow); if (!itemrow) { itemrow = getFocusedRow(); } if (!itemrow) { /* console.log('No active rows, skip command.');*/ return;} itemrow.setAttribute('data-spx-previewing', 'true'); // added in 1.1.1 to avoid double previewing. See also focusRow() data = {}; data.datafile = document.getElementById('datafile').value; data.epoch = itemrow.getAttribute('data-spx-epoch') || 0; data.command = 'preview'; // added in 1.1.0 working('Sending ' + data.command + ' request.'); ajaxpost('/gc/playout',data); heartbeat(308); // identifier } // previewItem function projectSettings() { // Open project settings page from rundown list page document.location.href += '/config' ; } // projectSettings function playItem(itemrow='', forcedCommand='', preventPlayout=false) { // Play item toggle. // // request ..... itemrow // forcedCommand is (for example) to stop item when continue steps are done // returns ..... ajax response message to GUI // // Will send data object with an index and playlist filename // and playout handler will parse required data and so on. // // FIXME: Bug found 2023-11-07 Step counter in DOM needs to be reset on Play // // v1.3.2 - added "preventPlayout" flag. This is used in the new "updateRundownItem" feature // in directplayout API endpoint is present. if (!itemrow) { itemrow = getFocusedRow(); } if (!itemrow) { return; } data = {}; data.datafile = document.getElementById('datafile').value; data.epoch = itemrow.getAttribute('data-spx-epoch') || 0; data.command = setItemButtonStates(itemrow, forcedCommand); // update buttons and return command (play/stop/playonce). ForcedCommand (stop) overrides. setMasterButtonStates(itemrow, 'from playItem'); // update master button UI if (preventPlayout) { console.log('Prevent playout flag is set, skipping playout.'); } else { working('Sending ' + data.command + ' request.'); ajaxpost('/gc/playout',data); } // Stats if (data.command == 'play' || data.command=="playonce") { heartbeat(302) } if (data.command == 'continue' ) { heartbeat(304) } if (data.command == 'stop' ) { heartbeat(306) } // playonce (for out type=none) command acts on the server, but the state will not be saved to JSON let isPlay = false; if (data.command=="play" || data.command=="playonce") { isPlay = true; // Added in 1.1.0 FIXME: This has some issues, previewing wrong items etc... if (document.getElementById('previewMode').value==='next') { console.log('Preview-next-on-play IS WIP and IS CURRENTY DISABLED'); // TODO: Enable this: // let nextRundownItem = getFocusedRow().nextElementSibling; // console.log('Next elementID ' + nextRundownItem.id, nextRundownItem); // previewItem(nextRundownItem); } } // Check for function_onPlay event (improved in 1.1.4): let onPlayField = itemrow.querySelector('[name="RundownItem[function_onPlay]"]'); if (isPlay && onPlayField && onPlayField.value!="") { // TODO: This needs more work! ============================= // Now this expects an array of 4 values, but function calls // can have 0 or several arguments... // ======================================================== // Changed in v1.1.4 (no other server changes, just "spx_gc.js") // Examples of valid "function_onPlay/nStop" values: // // myCustomMethod | {'foo': 'bar'} | 500 | f1 // myCustomMethod | 'foo' | 500 | f1 // // 1st: Function name (no parenthesis), // 2nd: an optional JSON object or a string argument, // 3rd: execution delay in ms. // 4th: one of the f-fields (value can be used as a condition in the function) let onPlayCommand = onPlayField.value.split('|') || []; let FunctionName = String(onPlayCommand[0] || 'noFunctionName').trim(); let FunctionArgs = String(onPlayCommand[1] || 'NoArgs').trim(); let ExecuteDelay = String(onPlayCommand[2] || '100').trim(); let ConditFField = String(onPlayCommand[3] || 'XXX').trim(); let conditionVal = null; let eventCondtFF = itemrow.querySelector('[data-update=' + ConditFField + ']'); // Improved in 1.3.0 - now supports also SELECT and CHECKBOX fields if (eventCondtFF) { switch (eventCondtFF.tagName) { case 'SELECT': conditionVal = eventCondtFF.options[eventCondtFF.selectedIndex].value; break; case 'INPUT': if (eventCondtFF.type == 'checkbox') { conditionVal = eventCondtFF.checked; } else { conditionVal = eventCondtFF.value; } break; default: conditionVal = eventCondtFF.value; } } setTimeout(function () { if (window[FunctionName]) { window[FunctionName]([FunctionArgs, conditionVal, data.epoch]); // Added data.epoch in 1.1.4 } else { showMessageSlider('Function "' + FunctionName + '" not found in current custom functions library file.', 'warn'); } }, parseInt(ExecuteDelay)); heartbeat(310); // identifier } // Check for function_onStop event (improved in 1.1.4): let onStopField = itemrow.querySelector('[name="RundownItem[function_onStop]"]'); if (data.command=="stop" && onStopField && onStopField.value!="") { // Changed in v1.1.4 (no other server changes, just "spx_gc.js") let onStopCommand = onStopField.value.split('|') || []; // console.log('[' + String(new Date().toLocaleTimeString()) + '] ' + 'STOP event: ' + onStopCommand); let FunctionName = String(onStopCommand[0] || 'noFunctionName').trim(); let FunctionArgs = String(onStopCommand[1] || 'NoArgs').trim(); let ExecuteDelay = String(onStopCommand[2] || '100').trim(); let ConditFField = String(onStopCommand[3] || 'XXX').trim(); let conditionVal = null; let eventCondtFF = itemrow.querySelector('[data-update=' + ConditFField + ']'); if (eventCondtFF) { conditionVal = eventCondtFF.value; } setTimeout(function () { // console.log('FunctionName:' + FunctionName); window[FunctionName]([FunctionArgs, conditionVal, data.epoch]); }, parseInt(ExecuteDelay)); heartbeat(312); // identifier } // Remember, onNext is handled in nextItem(); // auto-out trigger UI update (FIXME: verify auto-out works over direct API commands?!) let TimeoutAsString = itemrow.querySelector('[name="RundownItem[out]"]').value; // console.log('TimeoutAsString: ', TimeoutAsString); if (!isNaN(TimeoutAsString)) { // value is numerical, so // console.log('TimeoutAsString is a number, so it is a timeout value.'); if (data.command=="play" ) { // Changed in 1.0.9, failed in 1.0.8. // 1.0.12: Fixed looping issue found by Koen. let TimeoutAsInt = parseInt(TimeoutAsString); // console.log('Will stop playlistitem ' + itemrow.getAttribute('data-spx-epoch') + ' in ' + TimeoutAsInt + 'ms...'); var AutoOutTimerID = setTimeout(function () { if (itemrow.getAttribute('data-spx-onair')==='true'){ playItem(itemrow, 'stop'); } itemrow.querySelector('[data-spx-name="icon"]').classList.remove('playAuto'); }, TimeoutAsInt); itemrow.setAttribute('data-spx-timerid', AutoOutTimerID); itemrow.querySelector('[data-spx-name="icon"]').classList.add('playAuto'); } else { CancelOutTimerIfRunning(itemrow); } } } // playItem function playItemLocal(itemID) { // Just play an item to the local Controller preview // console.log('Sending playItemLocal request (spx-gc) ' + itemID + ' in 5 secs...'); data = {}; data.datafile = document.getElementById('datafile').value; data.epoch = itemID || 0; data.command = 'autoPlayLocal'; ajaxpost('/gc/playout',data); heartbeat(308) } // playItemLocal function renameRundown() { // Rename an existing rundown // Improved in 1.3.0 with validation var filename = document.getElementById('lists').value; if (!filename) {return} var foldname = document.getElementById("hidden_folder").value; while(true){ newname = prompt("Rename rundown to?", filename).trim(); if ( isValid(newname) ) { break; } else { alert("Please enter a valid file name, no special characters."); } } if (newname != null && newname != "") { data={}; data.orgname = filename + '.json'; data.newname = newname; data.foldnam = foldname; ajaxpost('/gc/renameRundown',data); setTimeout(function(){ document.location = '/show/' + foldname; }, 300); } } // renameRundown ended function versInt(semver){ // Returns a numeric value representing "1.0.0" formatted semantic version string. // This works as long as max value of each field is 99! // Moved from view-home in 1.1.0 let parts = semver.split("."); let MajorInt = parseInt(parts[0].trim())*100000 let MinorInt = parseInt(parts[1].trim())*1000 let PatchInt = parseInt(parts[2].trim()) let versInt = (MajorInt + MinorInt + PatchInt); // console.log('Semver ' + semver + ' = versInt ' + versInt); return versInt } function setItemButtonStates(itemrow, forcedCommand=''){ // Utility function which will toggle play indicators in // collapsed AND expanded views based on calling element's // current state. // forcedCommand is used to forcefully stop an item, which steps has run // out after continue is done... // v.1.0.8 also forced 'play' is being used by API v1 to force // play only, not just toggle.. // // Requests ..... playlistitem's eleIndex nro // Response ..... 'play' or 'stop' state after toggle // if ( itemrow.querySelector('[name="RundownItem[out]"]').value=="none" ) { // console.log('NONE out type in this graphic. Do not set the buttons in any way and return with playonce.'); if (forcedCommand=='stop') { return 'stop'; // Added in v 1.1.2 to prevent playing bumper at "Stop All" } return 'playonce'; } let CommandToExecute = 'play' // default action let EXPANDEDPLAY = itemrow.querySelector('[data-spx-name="playbutton"]'); let rows = document.querySelectorAll('.itemrow'); if (itemrow.getAttribute('data-spx-onair')=="true" || forcedCommand=='stop') { // so, we are playing and needs to stop CommandToExecute = 'stop'; // Reset current step counter. Added in 1.3.3 itemrow.setAttribute('data-spx-stepsleft',itemrow.querySelector('input[name="RundownItem[steps]"]').value); } if (itemrow.getAttribute('data-spx-onair')=="true" && forcedCommand=='play') { // so, we are playing and needs to play (API buttons for example) CommandToExecute = 'play'; } if ( CommandToExecute == 'stop' ) { // Convert button to PLAY button and execute stop command // console.log('was playing. so send STOP and make button PLAY'); itemrow.setAttribute('data-spx-onair','false'); itemrow.querySelector('[name="RundownItem[onair]"]').value='false'; itemrow.querySelector('[data-spx-name="icon"]').classList.remove('playTrue'); itemrow.querySelector('[data-spx-name="icon"]').classList.remove('playAuto'); itemrow.querySelector('[data-spx-name="icon"]').classList.add('playFalse'); EXPANDEDPLAY.innerText = EXPANDEDPLAY.getAttribute('data-spx-playtext'); EXPANDEDPLAY.classList.remove('bg_red'); EXPANDEDPLAY.classList.add('bg_green'); } if ( CommandToExecute == 'play' ) { // Convert button to STOP button and execute play command // first we need to dim all other elements which are playing on same output channel / layer // console.log('was stopped. so send PLAY and make button STOP'); let PlayoutConfig = itemrow.querySelector('[name="RundownItem[webplayout]"').value; rows.forEach(function (item, index) { let CurrentConfig = item.querySelector('[name="RundownItem[webplayout]"')?.value||0; if (item.getAttribute('data-spx-onair')=="true" && CurrentConfig==PlayoutConfig ) { console.log('Dom item ' + index + ' is the same, so dim it'); item.setAttribute('data-spx-onair','false'); item.querySelector('[name="RundownItem[onair]"]').value='false'; item.querySelector('[data-spx-name="icon"]').classList.remove('playTrue'); item.querySelector('[data-spx-name="icon"]').classList.add('playFalse'); CancelOutTimerIfRunning(itemrow); curExpandedPlay = item.querySelector('[data-spx-name="playbutton"]') curExpandedPlay.innerText = curExpandedPlay.getAttribute('data-spx-playtext'); curExpandedPlay.classList.remove('bg_red'); curExpandedPlay.classList.add('bg_green'); } }) EXPANDEDPLAY.innerText = EXPANDEDPLAY.getAttribute('data-spx-stoptext'); EXPANDEDPLAY.classList.remove('bg_green'); EXPANDEDPLAY.classList.add('bg_red'); itemrow.setAttribute('data-spx-onair','true'); itemrow.querySelector('[name="RundownItem[onair]"]').value='true'; itemrow.querySelector('[data-spx-name="icon"]').classList.remove('playFalse'); itemrow.querySelector('[data-spx-name="icon"]').classList.add('playTrue'); // // reset update buttons // itemrow.setAttribute('data-spx-changed','false'); // itemrow.querySelector('[data-spx-name="updatebutton"]').classList.add('disabled'); } if ( ife('identifier').value=="controller" ) { // finally show or hide delete buttons on all items rows.forEach(function (item, index) { if (item.getAttribute('data-spx-onair')=="true" ) { // console.log('Hide delete buttons of ' + item.id); item.querySelector('[data-spx-name="deletesmall"]').style.visibility="hidden" || (console.log('a')); item.querySelector('[data-spx-name="deletelarge"]').style.visibility="hidden" || (console.log('b')); } else { // console.log('Show delete buttons of ' + item.id); item.querySelector('[data-spx-name="deletesmall"]').style.visibility="visible" || (console.log('c')); item.querySelector('[data-spx-name="deletelarge"]').style.visibility="visible" || (console.log('d')); } }) } return CommandToExecute } // setItemButtonStates ended function post(path, params, method) { // Creates a hidden form in DOM, populates it and posts it to server. // Feels dumb, but works, so what the heck. // This is ONLY used when the POST command can yield a new page, // for in-page AJAX calls ajaxpost() should be used. const form = document.createElement('form'); form.method = method; form.action = path; for (const key in params) { if (params.hasOwnProperty(key)) { const hiddenField = document.createElement('input'); hiddenField.type = 'hidden'; hiddenField.name = key; hiddenField.value = params[key]; form.appendChild(hiddenField); } } document.body.appendChild(form); form.submit(); } // post ended function resizeInput() { // changes headline1 width this.style.width = (this.value.length + 1.2) + "ch"; } // resizeInput ended async function revealItemID(button) { let item = button.closest('.itemrow') let ID = item.getAttribute('data-spx-epoch'); copyText(ID) // alert("Item ID " + ID + " was copied to clipboard."); var newID = prompt("Item ID " + ID + " was copied to clipboard.\nYou can change the ID here:", ID); if (newID != null && newID != ID) { // alert("New ID " + newID) let file = document.getElementById('datafile').value; let URL = `/api/v1/changeItemID?rundownfile=${file}&ID=${ID}&newID=${newID}` let IDchanged axios.get(URL) .then(function (response) { showMessageSlider('Item renamed to ' + newID + '. Reload recommended.') item.setAttribute('data-spx-epoch', newID); item.querySelector('.copyid').setAttribute('onmouseover', `tip('Copy ID ${newID}')`); }) .catch(function (error) { showMessageSlider('Unable to rename. Use unique ID and try again.', 'error') }); // if (IDchanged) { // // success // item.setAttribute('data-spx-epoch', newID); // } else { // alert("Unable to change ID to " + newID + " (" + IDchanged + ").\nMake sure the ID is unique and try again."); // } } else { // alert("Same ID " + newID) } } // revealItemID async function revealItemTiming(button) { let curValue = button.textContent; let ID = button.closest('.itemrow').getAttribute('data-spx-epoch'); var newValue = prompt("You can change timing here. Use 'manual', 'none' or millisecond values (1000=1s):", curValue); if (newValue != null && newValue != curValue) { let file = document.getElementById('datafile').value; let URL = `/api/v1/changeItemData?rundownfile=${file}&ID=${ID}&key=out&newValue=${newValue}` axios.get(URL) .then(function (response) { showMessageSlider('Item out mode changed to ' + newValue) button.textContent = newValue; button.closest('.itemrow').querySelectorAll('input[id^=out]')[0].value = newValue; }) .catch(function (error) { showMessageSlider('Unable to change value, please try again.', 'error') }); } } // revealItemTiming function removeItemFromRundown(itemrow) { // Collect data for server processing data={}; data.command = "removeItemFromRundown"; data.foldername = document.getElementById('foldername').value; data.listname = document.getElementById('filebasename').value; data.datafile = document.getElementById('datafile').value; data.epoch = itemrow.getAttribute('data-spx-epoch'); // an ARRAY INDEX, not itemId! // Remove from DOM itemrow.remove(); event.stopPropagation(); // prevent trying to set focus to a deleted item // Update names for potential save at next sort updateFormIndexes() // send server command working('Sending ' + data.command + ' request.'); ajaxpost('',data); } // removeItemFromRundown ended function updateFormIndexes() { // This needs to run whenever items are sorted or removed. // Sorting routine needs the IndexList for saving sorting to a file. // When deleting, this is needed to re-assing form names. // 1.0.13 refactored to use epochs and not item indexes. // var forms = document.forms; let IndexList = [] let items = document.querySelectorAll('.itemrow'); items.forEach((item,index) => { IndexList.push(item.getAttribute('data-spx-epoch')); item.querySelector('form').name = "templates[" + index + "]"; }); // console.log('New form list sort order before saving to file', IndexList); return IndexList } // updateFormIndexes ended function rundownPopup(mode) { // get current url let url = window.location.href; url = url + '/light' window.open(url); } // rundownPopup ended function SaveNewSortOrder() { // This will save the rundown opened in SPX, for instance when // items dragged to new sorting order. Maybe other events also? working('saving'); let data={}; data.newTemplateOrderArr = updateFormIndexes(); // refactored to fix delete + sort bug. data.rundownfile = document.getElementById('datafile').value; // console.log('SaveNewSortOrder', data); ajaxpost('/gc/sortTemplates',data); } // SaveNewSortOrder ended function saveTemplateItemChanges(elementID) { // This is a utility function using Axios to POST // A FORM to server and we need to update GUI / DOM // accordingly. // Note: items might be sorted, so must check actual index // of called button. let FormNro = getDomIndexByElementId(elementID) working('Saving data from ElementID [' + elementID + '] = domIndex [' + FormNro + '] to server...'); // console.log('%c Saving data from ElementID [' + elementID + '] = domIndex [' + FormNro + '] to server...', 'background: #F0F; color: #000'); let urlPath='/gc/saveItemChanges'; data={}; data.DataFields=[]; const form = document.getElementById('form' + elementID); iterator = -1; [...form.elements].forEach((input,index) => { if (input.getAttribute('data-role')=="userEditable") { let updatedObj = {} updatedObj.field = input.getAttribute('data-update'); updatedObj.value = input.value; data.DataFields.push(updatedObj) iterator += 1; // console.log('Looking for ' + 'datapreview_' + FormNro + '_' + iterator); if (document.getElementById('datapreview_' + elementID + '_' + iterator)) { let temp1 = input.value.replace(/\n/g, ' '); // remove \n globally to support text areas let temp2 = temp1.replace(/\r/g, ''); // remove \r globally to support text areas document.getElementById('datapreview_' + elementID + '_' + iterator).innerText=temp2; } } }); data.rundownfile = document.getElementById('datafile').value; data.TemplateIdx = FormNro; // sort order on file and in dom should correspond directly axios.post(urlPath,data, { // headers: { 'Content-Type': 'multipart/form-data' } }) .then(function (response) { statusbar(response.data); working(''); ToggleExpand(elementID); // changed from domIndex to ElementID }) .catch(function (error) { working(''); if (error.response) { statusbar(error.response.data,'error') console.log(error.response.data); console.log(error.response.status); console.log(error.response.headers); } else if (error.request) { statusbar('SPX server connection error','error') console.log(error.request); } else { statusbar('SPX request error, see console','warn') console.log('Error', error.message); } console.log(error.config); }); } // saveTemplateItemChanges function saveTemplateItemChangesByElement(itemrow) { working('Saving changed data to server...'); data={}; data.DataFields=[]; const form = itemrow.querySelectorAll('form')[0]; [...form.elements].forEach((input,index) => { if (input.getAttribute('data-role')=="userEditable") { let updatedObj = {} updatedObj.field = input.getAttribute('data-update'); updatedObj.value = input.value; if (input.type === 'checkbox') { if (input.checked) { updatedObj.value = '1'; } else { updatedObj.value = '0'; } } data.DataFields.push(updatedObj) } }); data.rundownfile = document.getElementById('datafile').value; data.epoch = itemrow.getAttribute("data-spx-epoch"); axios.post('/gc/saveItemChanges',data, {}) .then(function (response) { statusbar(response.data[0]); // the 2nd item from response is the html snippet for the GUI (improved in 1.0.7) itemrow.querySelector('.truncate').innerHTML = response.data[1]; working(''); ToggleExpand(itemrow); }) .catch(function (error) { working(''); if (error.response) { statusbar(error.response.data,'error') console.log(error.response.data); console.log(error.response.status); console.log(error.response.headers); } else if (error.request) { statusbar('SPX server connection error','error') console.log(error.request); } else { statusbar('SPX request error, see console','warn') console.log('Error', error.message); } console.log(error.config); }); } // saveTemplateItemChanges function showItemIDs(show=true) { // Show item ID's in GUI let rows = document.querySelectorAll('.itemrow'); rows.forEach((item,index) => { if (show==true) { item.querySelector('.utilityOverlay').classList.remove('hidden'); // document.getElementById('rundownInfoMessage').style='display:flex;'; } else if (show==false) { item.querySelector('.utilityOverlay').classList.add('hidden'); // document.getElementById('rundownInfoMessage').style='display:none;'; } else { item.querySelector('.utilityOverlay').classList.toggle('hidden'); } }); } // showItemIDs ended function setMasterButtonStates(itemrow, debugMessage='') { // this triggers when row focuses or when Play or Stop pressed or item changed. if (!document.getElementById('MasterTOGGLE')) return; // this page does not have buttons... let TOGGLEBUTTON = document.getElementById('MasterTOGGLE'); let UPDATEBUTTON = document.getElementById('MasterUPDATE'); let CONTINBUTTON = document.getElementById('MasterCONTINUE'); if (document.querySelectorAll('.itemrow').length<1){ // no items on page, so disable buttons and bale out if (TOGGLEBUTTON) {TOGGLEBUTTON.classList.add('disabled')}; if (UPDATEBUTTON) {UPDATEBUTTON.classList.add('disabled')}; if (CONTINBUTTON) {CONTINBUTTON.classList.add('disabled')}; return } // console.clear(); // console.log('States check: "' + typeof itemrow + '"', itemrow); // console.log('CHECKING: setMasterButtonStates of item ' + itemrow.getAttribute('data-spx-epoch') + ' (' + debugMessage + ')'); // this gfx only has in-animation if ( itemrow.querySelector('input[name="RundownItem[out]"]') && itemrow.querySelector('input[name="RundownItem[steps]"]').value=="none" ) { // console.log('This items outmode = none, so not changing button states'); // maybe add a play and setTimeout to reset quickly... return; } var stepsVal = itemrow.querySelector('input[name="RundownItem[steps]"]').value || 0; var stepsleft= itemrow.getAttribute('data-spx-stepsleft'); var steps = parseInt(stepsVal); var onAirState = String(itemrow.getAttribute('data-spx-onair') || false) var changState = String(itemrow.getAttribute('data-spx-changed') || false) onAirState = onAirState.toLowerCase(); changState = changState.toLowerCase(); var onair = (onAirState == 'true'); // make bool var chngd = (changState == 'true'); // make bool // console.log('Onair', onair, 'Changed', chngd, 'Steps', steps ); // CONTINUE CONTINBUTTON.classList.add('disabled'); if (stepsleft > 1 && onair ) { setTimeout(function () { CONTINBUTTON.classList.remove('disabled'); }, 3); } // PLAY - STOP TOGGLE if (onair) { // console.log('Focused item IS playing (so change it to STOP)'); TOGGLEBUTTON.classList.remove('disabled') TOGGLEBUTTON.innerText = TOGGLEBUTTON.getAttribute('data-spx-stoptext'); TOGGLEBUTTON.classList.remove('bg_green'); TOGGLEBUTTON.classList.add('bg_red'); } else { // console.log('Focused item NOT playing (so change it to PLAY)'); TOGGLEBUTTON.classList.remove('disabled') TOGGLEBUTTON.innerText = TOGGLEBUTTON.getAttribute('data-spx-playtext'); TOGGLEBUTTON.classList.remove('bg_red'); TOGGLEBUTTON.classList.add('bg_green'); } } // setTGButtonStates ended function hideMessageSlider() { if (!document.getElementById('messageSlider')) { return } anime({ targets: '#messageSlider', opacity: 0, translateY: [0,-200], translateX: '-50%', duration: 300, easing: 'easeInCubic' }); } // hideMessageSlider function showMessageSlider(msg, type='info', persist=false) { // show a sliding message at the top of the page // type: happy, info, warn, error // persist: if true, do not auto-hide // Example usage in templates (or extensions, remember to // add dom element:) /*
*/ /* top?.showMessageSlider?.('Hello graphics operator!', 'happy'); */ let txt = msg; let typ = type; // happy, info, warn, error (classes happyMsg, infoMsg, warnMsg, errorMsg...) let domElement if (document.getElementById('messageSlider')) { domElement = document.getElementById('messageSlider'); } else { console.warn('SPX message: ' + msg); return; } switch (msg) { case 'invalidCSV': txt = 'Invalid CSV. See help: import and export' typ = 'error' } domElement.innerHTML = txt; domElement.classList = typ + 'Msg'; domElement.style.transform = 'translateX(-50%)'; // init pos domElement.style.opacity = 0; anime({ targets: '#messageSlider', opacity: [0,1], translateY: [-200,0], translateX: '-50%', delay: 100, duration: 200, easing: 'easeOutCubic' }); if (persist) {return}; // do not animate out if (messageSliderOutTimer) { clearTimeout(messageSliderOutTimer) } messageSliderOutTimer = setTimeout(() => { anime({ targets: '#messageSlider', opacity: [1,0], translateY: [0,-200], translateX: '-50%', duration: 300, easing: 'easeInCubic' }); }, 2000); } // showMessageSlider function spx_system(cmd,servername='') { // system command handler let sysCmd = cmd.toUpperCase() let data = {}; switch (sysCmd) { case 'OPENWEBRENDERER': window.open("/renderer", 'gcwebrender', 'width=1920,height=1080,scrollbars=yes,location=yes,status=yes'); break; case 'OPENWEBRENDERER_SCALABLE': var w = 1280; var h = 720; var left = (screen.width/2)-(w/2); var top = (screen.height/2)-(h/2); var OPT = '_blank,toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width='+w+', height='+h+', top='+top+', left='+left; window.open('/renderer/scalable', 'gcwebrender2', OPT); break; case 'OPENDATAFOLDER': data.command = 'DATAFOLDER'; break; case 'OPENTEMPLATEFOLDER': data.command = 'TEMPLATEFOLDER'; break; case 'CHECKCONNECTIONS': // console.log('Checking connections to servers...', servername); data.command = 'CHECKCONNECTIONS'; data.server = servername; break; case 'RESTARTSERVER': // this will kill 'nodejs' -server and 'forever' utility will restart it again and again... data.command = 'RESTARTSERVER'; data.reloadPage = true; break; default: console.log('Unknown spx_system identifer: ' + cmd); break; } AJAXGET('/CCG/system/' + JSON.stringify(data)); if (data.reloadPage) { document.getElementById('SmartPX_App').style.opacity = '0.4'; setTimeout('location.reload()', 1000); } } // end spx_system function tip(msg) { // request ..... // returns ..... // Describe the function here e = document.getElementById('statusbar') || null; if (e) { document.getElementById('statusbar').innerText=msg; event.stopPropagation(); // playServerAudio('beep', 'We are in TIP function'); } } // tip mgsed function e(ID) { return document.getElementById(ID); } // e function ife(ID) { if (document.getElementById(ID)) { // console.log('IFE Element found: ' + ID); return document.getElementById(ID); } else { // console.log('IFE Element NOT found: ' + ID); return false; } } // ife function spxInit() { // executes on page load: // - load values from localStorage // - init Sortable // Init sortable and saveData onEnd if ( ife('identifier').value=="controller" ) { sortable = Sortable.create(itemList, { handle: '.handle', animation: 150, disabled: false, // sortable.option("disabled", true); // TAI false onEnd: function (evt) { SaveNewSortOrder(); }, }); } focusRow(0); AppState("DEFAULT"); spx_system('CHECKCONNECTIONS'); document.getElementById('itemList').style.opacity=1; } // end spxInit function setProfile(profileName) { // FIXME: remove? // change profile to profileName and save to localStorage if (profileName == '') { // retrieve from localStorage profileName = localStorage.SPX_CT_ProfileName || '...'; } document.getElementById('profname').innerText = profileName; localStorage.SPX_CT_ProfileName = profileName; } // setProfile ended function statusbar(sMsg, sLevel="x") { /* Usage - statusbar('This is a normal message') - statusbar('Hot warning', "warn") - statusbar('Red alert', "error") */ if (!document.getElementById('statusbar')) { return } // not in controller let msglevel = sLevel.toUpperCase(); // console.log('statusbar level: [' + msglevel +'], msg: [' + sMsg + '].'); document.getElementById('statusbar').classList="statusbar"; switch (msglevel){ case "ERROR": document.getElementById('statusbar').classList.add("statusBarError"); break; case "WARN": document.getElementById('statusbar').classList.add("statusBarWarn"); break; } document.getElementById('statusbar').innerText=sMsg; } // statusbar function stopAll() { // Will send STOP commands to all layers used by current rundown. // Timeout here allows some time for server to handle the incoming commands. // TODO: Far from elegant but kind of works. A better approach would be to // develop a server-side function for this. // Function implemented in v1.0.8 from ExtraFunctions -library let ITEMS = document.querySelectorAll('.itemrow'); ITEMS.forEach(function (templateItem, itemNro) { setTimeout(function(){ // continueUpdateStop('stop', templateItem); // slower playItem(templateItem, 'stop'); // setItemButtonStates(templateItem, 'stop') // just the UI }, (itemNro * 40)); // 20, 40, 60, 80ms etc... }); } // stopAll function swap2HTMLntities(str){ // This fixes issue #5 re special characters str = str.replace(/\\/g, "\") // html entity for backslash str = str.replace(/"/g, """) // html entity for dblquote str = encodeURIComponent(str); // handle the rest return str; } // end swap2HTMLntities function tip(msg) { // request ..... // returns ..... // Describe the function here e = document.getElementById('statusbar') || null; if (e) { document.getElementById('statusbar').innerText=msg; event.stopPropagation(); // playServerAudio('beep', 'We are in TIP function'); } } // tip mgsed function toggleAutoplay(slider, targ) { let Cfgdata = {}; Cfgdata.key = 'autoplayLocalRenderer' if (slider.checked) { document.getElementById(targ).innerText = 'YES'; Cfgdata.val = true; } else { document.getElementById(targ).innerText = 'NO'; Cfgdata.val = false; } // console.log('Sending config change to server', Cfgdata); ajaxpost('/gc/saveConfigChanges',Cfgdata); } // toggleAutoplay function toggleLRendererHandler(slider, targ) { let Cfgdata = {}; Cfgdata.key = 'disableLocalRenderer' if (slider.checked) { document.getElementById(targ).innerText = 'YES'; document.getElementById('previewIF').src = '/templates/empty.html'; document.getElementById('LocalRendererDisabledNotification').style.opacity = 1; Cfgdata.val = true; } else { document.getElementById('previewIF').src = '/renderer'; document.getElementById(targ).innerText = 'NO'; document.getElementById('LocalRendererDisabledNotification').style.opacity = 0; Cfgdata.val = false; } // console.log('Sending config change to server', Cfgdata); ajaxpost('/gc/saveConfigChanges',Cfgdata); } // toggleLRendererHandler function toggleSwitchHandler(slider, targ, type) { // When program/preview toggle buttons are clicked. // console.log('toggleSwitchHandler', slider, targ); let data = {} data.type = type; let handle = slider.querySelector('.vc-handle'); if (slider.checked) { data.command = 'open'; document.getElementById(targ).innerText = 'ON'; } else { data.command = 'close'; document.getElementById(targ).innerText = 'OFF'; } handleRendererPopups(data) } // toggleSwitchHandler function toggleRundownSettings() { // Toggle the rundown settings panel let optionsZone = document.getElementById('rdOptionsCollapsible'); if (optionsZone.style.maxHeight){ optionsZone.style.maxHeight = null; setTimeout(function(){optionsZone.style.display = 'none';}, 300); } else { optionsZone.style.display = 'block'; optionsZone.style.maxHeight = optionsZone.scrollHeight + "px"; } } // toggleRundownSettings function toggleTip(targetField, source, attr) { document.getElementById(targetField).innerText = source.getAttribute(attr); } // toggleTip function toggleActive(ServerName='') { // Will enable / disable commands to the server // CCG/disable {server:'OVERLAY, disable:false} let e = document.getElementById('server'+ ServerName); let targetState = false; if (e && e.classList.contains('disabledServer')) { // Enable e.classList.remove('disabledServer') targetState = false } else { // Disable e.classList.add('disabledServer') targetState = true } let data = {}; data.server = ServerName; data.disabled = targetState; working('Changing server connection state to [' + targetState + ']...'); ajaxpost('/CCG/disable',data); } // toggleActive function ToggleExpand(rowelement='') { if (!rowelement){ // rowelement not given, get focused line rowelement=getFocusedRow(); } // console.log('Toggle row id ' + rowelement.id); let Expanded = rowelement.querySelector('#Expanded') let Collapsed = rowelement.querySelector('#Collapsed') if (Collapsed.style.display=="none") { // We were open, so lets COLLAPSE Collapsed.style.display="block"; Expanded.style.display="none"; AppState('DEFAULT'); } else { // We were closed, so lets EXPAND and set focus to first text field Collapsed.style.display="none"; Expanded.style.display="block"; if (Expanded.querySelectorAll('.gcinput').length>0) { // if there are input fields, go to editor mode (and disable dragsort) let firstFormElement = Expanded.querySelectorAll('.gcinput')[0]; if (firstFormElement.type=="text") { firstFormElement.focus(); firstFormElement.select(); } AppState('EDITING'); } } } // ToggleExpand function updateItem(itemrow) { // request ..... rundown template index // returns ..... ajax response message to GUI // Will send data object with an index and playlist filename // and playout handler will parse required data and so on. if (!itemrow) {itemrow = getFocusedRow() } working('Sending update request.'); data = {}; data.command = 'update'; data.datafile = document.getElementById('datafile').value; data.relpath = itemrow.querySelector('[name="RundownItem[relpath]"]').value; data.playserver = itemrow.querySelector('[name="RundownItem[playserver]"]').value; data.playchannel = itemrow.querySelector('[name="RundownItem[playchannel]"]').value; data.playlayer = itemrow.querySelector('[name="RundownItem[playlayer]"]').value; data.webplayout = itemrow.querySelector('[name="RundownItem[webplayout]"]').value; data.epoch = itemrow.getAttribute('data-spx-epoch'); data.fields = []; let CurrentDomFields = itemrow.querySelectorAll("[data-update]"); CurrentDomFields.forEach((item,index) => { let formField={}; formField.field = item.getAttribute('data-update'); formField.value = item.value; if (item.type === 'checkbox') { if (item.checked) { formField.value = '1' } else { formField.value = '0' } } data.fields.push(formField); }); // Note, data here IS in internal array format [{"field":"f1", "value":"Hello"},{"field":"f2", "value":"World"},] // and MUST NOT change. Format it downstream for different renderers. ajaxpost('/gc/playout',data, 'true'); } // updateItem function playServerAudio(sfx,message=''){ console.log('%cPlaying sound (' + sfx + ') on server. Log: ' + message + '.', 'background: #622; color: #fff'); data = {}; data.sound = sfx; data.info = message; ajaxpost('/gc/playaudio/', data) } // playServerAudio function working(StatusMsg){ if ( !document.getElementById('working') ) return ; // not in controller if (StatusMsg==""){ document.getElementById('working').innerText=""; document.getElementById('working').style.display="none"; } else { document.getElementById('working').innerText=StatusMsg; document.getElementById('working').style.display="inline-block"; } } //working function toggleNormalRenderer(cmd) { // Added in 1.1.0 // This [closing of popup] can happen in any SPX view, // not necessarily in the controller view... let Cfgdata = {}; Cfgdata.key = 'renderer' if (cmd == 'open') { Cfgdata.val = 'popup'; } else { Cfgdata.val = 'normal'; } ajaxpost('/gc/saveConfigChanges',Cfgdata); showMessageSlider('Renderer changed to ' + Cfgdata.val + '.') if ( !document.getElementById('previewIF') ) return ; // not in controller if (cmd == 'open') { // Hide inline renderer, use popup document.getElementById('previewBG').style.display='none'; document.getElementById('previewIF').src='/templates/empty.html'; } else { // Use normal renderer document.getElementById('previewBG').style.display='block'; document.getElementById('previewIF').src='/renderer'; } } // toggleNormalRenderer // This script is gotten straight from https://www.w3schools.com/howto/howto_js_draggable.asp function dragElement(elmnt) { if (!elmnt) { return } // element not found, bail out var pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0; if (document.getElementById(elmnt.id + "HeaderBar")) { // if present, the header is where you move the DIV from: document.getElementById(elmnt.id + "HeaderBar").onmousedown = dragMouseDown; } else { // otherwise, move the DIV from anywhere inside the DIV: elmnt.onmousedown = dragMouseDown; } function dragMouseDown(e) { e = e || window.event; e.preventDefault(); // get the mouse cursor position at startup: pos3 = e.clientX; pos4 = e.clientY; document.onmouseup = closeDragElement; // call a function whenever the cursor moves: document.onmousemove = elementDrag; } function elementDrag(e) { e = e || window.event; e.preventDefault(); // calculate the new cursor position: pos1 = pos3 - e.clientX; pos2 = pos4 - e.clientY; pos3 = e.clientX; pos4 = e.clientY; // set the element's new position: elmnt.style.top = (elmnt.offsetTop - pos2) + "px"; elmnt.style.left = (elmnt.offsetLeft - pos1) + "px"; } function closeDragElement() { // stop moving when mouse button is released: document.onmouseup = null; document.onmousemove = null; } }