From 014a3b04d268c760405951397b86b634cf630b29 Mon Sep 17 00:00:00 2001 From: Nathan Coad Date: Sat, 26 Sep 2026 13:39:35 +1000 Subject: [PATCH] Phase 5b and Phase 6: TypeScript restructure, jsdom tests, regex filters and per-playlist buffer in the UI TypeScript: - menu_ts.ts split into menu.ts, content.ts, popup.ts and xmltv.ts; the other files drop their _ts suffix. Bundle order fixed in ts/tsconfig.json. - All 94 string event handlers (setAttribute("onclick", "javascript: ...")) replaced with addEventListener closures. changeButtonAction, which rewrote an onclick attribute from select values, is gone. - Settings rows are generated from one SETTINGS_FIELDS table instead of ~20 copy-pasted blocks; rendered HTML is byte-identical to before. saveSettings now serialises password inputs, so a changed Plex token is actually sent (the server treats the mask as unchanged). - createLayout rebuilds the menu list only when the set of visible items changes, so focus in the menu survives a refresh. - announce()/alertUser(): alerts are mirrored into an aria-live region. - Filter popup offers a third type, Regular Expression (regex-filter), and the filter table labels it. Playlist popups get a Buffer select (default / none / xTeVe / FFmpeg / VLC) saved as the playlist's "buffer" parameter; the tuner field stays editable when the playlist's own buffer is active. Tests: tests/ holds 27 jsdom tests (Node's built-in runner, jsdom pinned) that load the built bundle with a fixture server payload: menu visibility rules, mapping table renders names as text, settings panel fields, popup flows, sorting, bulk select, layout refresh keeps nodes, live region, regex option, buffer select. npm test runs in the Drone webui-check step. README-DEV documents the layout and the test workflow. --- .drone.yml | 1 + README-DEV.md | 7 +- html/js/app.js | 2316 +++++++++++++++++-------------------- package-lock.json | 532 +++++++++ package.json | 4 +- tasks/todo.md | 22 +- tests/app.test.js | 218 ++++ tests/fixture.js | 162 +++ tests/handlers.test.js | 226 ++++ tests/harness.js | 127 ++ tests/layout.test.js | 116 ++ ts/base.ts | 51 +- ts/configuration.ts | 2 +- ts/content.ts | 764 +++++++++++++ ts/menu.ts | 414 +++++++ ts/menu_ts.ts | 2477 ---------------------------------------- ts/network.ts | 6 +- ts/popup.ts | 1171 +++++++++++++++++++ ts/settings.ts | 714 +++--------- ts/tsconfig.json | 17 +- ts/xmltv.ts | 206 ++++ 21 files changed, 5257 insertions(+), 4296 deletions(-) create mode 100644 tests/app.test.js create mode 100644 tests/fixture.js create mode 100644 tests/handlers.test.js create mode 100644 tests/harness.js create mode 100644 tests/layout.test.js create mode 100644 ts/content.ts create mode 100644 ts/menu.ts delete mode 100644 ts/menu_ts.ts create mode 100644 ts/popup.ts create mode 100644 ts/xmltv.ts diff --git a/.drone.yml b/.drone.yml index 1d68e89..d34cf48 100644 --- a/.drone.yml +++ b/.drone.yml @@ -18,6 +18,7 @@ steps: - cp html/js/app.js /tmp/app.js.committed - npm ci --no-audit --no-fund - npm run build + - npm test - cmp /tmp/app.js.committed html/js/app.js || (echo "html/js/app.js is stale - run 'npm run build' and commit the result" && exit 1) - name: go-build diff --git a/README-DEV.md b/README-DEV.md index bc9bb49..748eab2 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -29,12 +29,15 @@ The web interface is embedded with `go:embed` from `html/`, so a bare clone buil The UI is plain TypeScript compiled as global scripts (no modules yet) into a single bundle, `html/js/app.js`, which is committed. CI rebuilds it and fails if the committed file is stale. ```bash -npm ci # installs the pinned TypeScript compiler +npm ci # installs the pinned TypeScript compiler and jsdom npm run build # ts/*.ts -> html/js/app.js npm run check # type-check only +npm test # jsdom tests under tests/ (Node's built-in runner) ``` -File order in the bundle is fixed in `ts/tsconfig.json` and matches the old per-page script order. All pages load the same bundle. +File order in the bundle is fixed in `ts/tsconfig.json`: `network`, `menu`, `content`, `popup`, `xmltv`, `settings`, `logs`, `base`, `configuration`, `authentication`. Classes must be defined before the top-level statements in later files that instantiate them. All pages load the same bundle. + +The tests load the built bundle into a jsdom window (`tests/harness.js`) with a fixture server payload (`tests/fixture.js`), so they exercise the real rendering code. Run `npm run build` before `npm test`. English strings are inlined in the TypeScript. There is no language layer. diff --git a/html/js/app.js b/html/js/app.js index 319b5b4..896809e 100644 --- a/html/js/app.js +++ b/html/js/app.js @@ -194,7 +194,7 @@ class Server { setConnectionState("online"); if (response["status"] == false) { setConnectionState("offline"); - alert(response["err"]); + alertUser(response["err"]); if (response.hasOwnProperty("reload")) { location.reload(); } @@ -226,7 +226,7 @@ class Server { window.location = response["openLink"]; } if (response.hasOwnProperty("alert")) { - alert(response["alert"]); + alertUser(response["alert"]); } if (response.hasOwnProperty("reload")) { location.reload(); @@ -248,7 +248,7 @@ class Server { } setConnectionState(state); if (WS_AVAILABLE == false && !isLogUpdate && requestCmd != "getServerConfig") { - alert("No websocket connection to xTeVe could be established. Check your network configuration."); + alertUser("No websocket connection to xTeVe could be established. Check your network configuration."); } }); } @@ -281,7 +281,7 @@ class MainMenuItem extends MainMenu { } createItem() { var item = document.createElement("LI"); - item.setAttribute("onclick", "javascript: openThisMenu(this)"); + item.addEventListener("click", function () { openThisMenu(item); }); item.setAttribute("id", this.id); item.setAttribute("data-menu", this.menuKey); var img = this.createIMG(this.imgSrc); @@ -310,599 +310,6 @@ class MainMenuItem extends MainMenu { //console.log(this.menuKey, this.tableHeader); } } -class Content { - constructor() { - this.DocumentID = "content"; - this.TableID = "content_table"; - this.headerClass = "content_table_header"; - this.interactionID = "content-interaction"; - } - createHeadline(value) { - var element = document.createElement("H3"); - element.textContent = value; - return element; - } - createHR() { - var element = document.createElement("HR"); - return element; - } - createInteraction() { - var element = document.createElement("DIV"); - element.setAttribute("id", this.interactionID); - return element; - } - createDIV() { - var element = document.createElement("DIV"); - element.id = this.DivID; - return element; - } - createTABLE() { - var element = document.createElement("TABLE"); - element.id = this.TableID; - return element; - } - createTableRow() { - var element = document.createElement("TR"); - element.className = this.headerClass; - return element; - } - createTableContent(menuKey) { - var data = new Object(); - var rows = new Array(); - switch (menuKey) { - case "playlist": - var fileTypes = new Array("m3u", "hdhr"); - fileTypes.forEach(fileType => { - data = SERVER["settings"]["files"][fileType]; - var keys = getObjKeys(data); - keys.forEach(key => { - var tr = document.createElement("TR"); - tr.id = key; - tr.setAttribute('onclick', 'javascript: openPopUp("' + fileType + '", this)'); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["name"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - if (SERVER["settings"]["buffer"] != "-") { - cell.value = data[key]["tuner"]; - } - else { - cell.value = "-"; - } - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["last.update"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["provider.availability"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["type"].toUpperCase(); - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["compatibility"]["streams"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["compatibility"]["group.title"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["compatibility"]["tvg.id"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["compatibility"]["stream.id"]; - tr.appendChild(cell.createCell()); - rows.push(tr); - }); - }); - break; - case "filter": - delete SERVER["settings"]["filter"][-1]; - data = SERVER["settings"]["filter"]; - var keys = getObjKeys(data); - keys.forEach(key => { - var tr = document.createElement("TR"); - tr.id = key; - tr.setAttribute('onclick', 'javascript: openPopUp("' + data[key]["type"] + '", this)'); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["name"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - switch (data[key]["type"]) { - case "custom-filter": - cell.value = "Custom"; - break; - case "group-title": - cell.value = "Group"; - break; - default: - break; - } - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["filter"]; - tr.appendChild(cell.createCell()); - rows.push(tr); - }); - break; - case "xmltv": - var fileTypes = new Array("xmltv"); - fileTypes.forEach(fileType => { - data = SERVER["settings"]["files"][fileType]; - var keys = getObjKeys(data); - keys.forEach(key => { - var tr = document.createElement("TR"); - tr.id = key; - tr.setAttribute('onclick', 'javascript: openPopUp("' + fileType + '", this)'); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["name"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["last.update"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["provider.availability"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["compatibility"]["xmltv.channels"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["compatibility"]["xmltv.programs"]; - tr.appendChild(cell.createCell()); - rows.push(tr); - }); - }); - break; - case "users": - var fileTypes = new Array("users"); - fileTypes.forEach(fileType => { - data = SERVER[fileType]; - var keys = getObjKeys(data); - keys.forEach(key => { - var tr = document.createElement("TR"); - tr.id = key; - tr.setAttribute('onclick', 'javascript: openPopUp("' + fileType + '", this)'); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["data"]["username"]; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = "******"; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - if (data[key]["data"]["authentication.web"] == true) { - cell.value = "✓"; - } - else { - cell.value = "-"; - } - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - if (data[key]["data"]["authentication.pms"] == true) { - cell.value = "✓"; - } - else { - cell.value = "-"; - } - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - if (data[key]["data"]["authentication.m3u"] == true) { - cell.value = "✓"; - } - else { - cell.value = "-"; - } - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - if (data[key]["data"]["authentication.xml"] == true) { - cell.value = "✓"; - } - else { - cell.value = "-"; - } - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - if (data[key]["data"]["authentication.api"] == true) { - cell.value = "✓"; - } - else { - cell.value = "-"; - } - tr.appendChild(cell.createCell()); - rows.push(tr); - }); - }); - break; - case "mapping": - BULK_EDIT = false; - createSearchObj(); - checkUndo("epgMapping"); - console.log("MAPPING"); - data = SERVER["xepg"]["epgMapping"]; - var keys = getObjKeys(data); - keys.forEach(key => { - var tr = document.createElement("TR"); - tr.id = key; - //tr.setAttribute('oncontextmenu', 'javascript: rightClick(this)') - switch (data[key]["x-active"]) { - case true: - tr.className = "activeEPG"; - break; - case false: - tr.className = "notActiveEPG"; - break; - } - // Bulk - var cell = new Cell(); - cell.child = true; - cell.childType = "BULK"; - cell.value = false; - tr.appendChild(cell.createCell()); - // Kanalnummer - var cell = new Cell(); - cell.child = true; - cell.childType = "INPUTCHANNEL"; - cell.value = data[key]["x-channelID"]; - //td.setAttribute('onclick', 'javascript: changeChannelNumber("' + key + '", this)') - tr.appendChild(cell.createCell()); - // Logo - var cell = new Cell(); - cell.child = true; - cell.childType = "IMG"; - cell.imageURL = data[key]["tvg-logo"]; - tr.appendChild(cell.createCell()); - // Kanalname - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.className = data[key]["x-category"]; - cell.value = data[key]["x-name"]; - tr.appendChild(cell.createCell()); - // Playlist - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - //cell.value = data[key]["_file.m3u.name"] - cell.value = getValueFromProviderFile(data[key]["_file.m3u.id"], "m3u", "name"); - tr.appendChild(cell.createCell()); - // Gruppe (group-title) - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = data[key]["x-group-title"]; - tr.appendChild(cell.createCell()); - // XMLTV Datei - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - if (data[key]["x-xmltv-file"] != "-") { - cell.value = getValueFromProviderFile(data[key]["x-xmltv-file"], "xmltv", "name"); - } - else { - cell.value = data[key]["x-xmltv-file"]; - } - tr.appendChild(cell.createCell()); - // XMLTV Kanal - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - //var value = str.substring(1, 4); - var value = data[key]["x-mapping"]; - if (value.length > 20) { - value = data[key]["x-mapping"].substring(0, 20) + "..."; - } - cell.value = value; - tr.appendChild(cell.createCell()); - var cell = new Cell(); - cell.child = true; - cell.childType = "EDIT"; - cell.value = "Edit"; - var editTd = cell.createCell(); - var editButton = editTd.firstChild; - editButton.setAttribute('onclick', 'javascript: openPopUp("mapping", this)'); - editButton.setAttribute("id", key); - editButton.setAttribute("aria-label", "Edit " + data[key]["x-name"]); - tr.appendChild(editTd); - rows.push(tr); - }); - break; - case "settings": - alert(); - break; - default: - console.log("Table content (menuKey):", menuKey); - break; - } - return rows; - } -} -class Cell { - createCell() { - var td = document.createElement("TD"); - if (this.child == true) { - var element; - switch (this.childType) { - case "P": - element = document.createElement(this.childType); - element.textContent = this.value; - element.className = this.className; - break; - case "INPUT": - element = document.createElement(this.childType); - element.value = this.value; - element.type = "text"; - break; - case "INPUTCHANNEL": - element = document.createElement("INPUT"); - element.setAttribute("onchange", "javscript: changeChannelNumber(this)"); - element.value = this.value; - element.type = "text"; - break; - case "BULK": - element = document.createElement("INPUT"); - element.checked = this.value; - element.type = "checkbox"; - element.className = "bulk hideBulk"; - element.addEventListener("click", function (event) { - scheduleChannelRangeSelection(element, event); - }); - break; - case "BULK_HEAD": - element = document.createElement("INPUT"); - element.checked = this.value; - element.type = "checkbox"; - element.className = "bulk hideBulk"; - element.setAttribute("onclick", "javascript: selectAllChannels()"); - break; - case "IMG": - element = document.createElement(this.childType); - element.setAttribute("src", this.imageURL); - if (this.imageURL != "") { - element.setAttribute("onerror", "javascript: this.onerror=null;this.src=''"); - //onerror="this.onerror=null;this.src='missing.gif';" - } - break; - case "EDIT": - element = document.createElement("INPUT"); - element.type = "button"; - element.value = this.value; - element.className = "mapping-edit-button"; - break; - } - td.appendChild(element); - } - else { - td.textContent = this.value; - } - if (this.onclick == true) { - td.setAttribute("onclick", this.onclickFunktion); - td.className = "pointer"; - } - if (this.tdClassName != undefined) { - td.className = this.tdClassName; - } - return td; - } -} -class ShowContent extends Content { - constructor(menuID) { - super(); - this.menuID = menuID; - } - createInput(type, name, value) { - var input = document.createElement("INPUT"); - input.setAttribute("type", type); - input.setAttribute("name", name); - input.setAttribute("value", value); - return input; - } - show() { - COLUMN_TO_SORT = -1; - // Alten Inhalt löschen - var doc = document.getElementById(this.DocumentID); - doc.innerHTML = ""; - showPreview(false); - // Überschrift - var headline = menuItems[this.menuID].headline; - var menuKey = menuItems[this.menuID].menuKey; - var h = this.createHeadline(headline); - doc.appendChild(h); - var hr = this.createHR(); - doc.appendChild(hr); - // Interaktion - var div = this.createInteraction(); - doc.appendChild(div); - var interaction = document.getElementById(this.interactionID); - switch (menuKey) { - case "playlist": - var input = this.createInput("button", menuKey, "New"); - input.setAttribute("id", "-"); - input.setAttribute("onclick", 'javascript: openPopUp("playlist")'); - interaction.appendChild(input); - break; - case "filter": - var input = this.createInput("button", menuKey, "New"); - input.setAttribute("id", -1); - input.setAttribute("onclick", 'javascript: openPopUp("filter", this)'); - interaction.appendChild(input); - break; - case "xmltv": - var input = this.createInput("button", menuKey, "New"); - input.setAttribute("id", "xmltv"); - input.setAttribute("onclick", 'javascript: openPopUp("xmltv")'); - interaction.appendChild(input); - break; - case "users": - var input = this.createInput("button", menuKey, "New"); - input.setAttribute("id", "users"); - input.setAttribute("onclick", 'javascript: openPopUp("users")'); - interaction.appendChild(input); - break; - case "mapping": - showElement("loading", true); - var input = this.createInput("button", menuKey, "Save"); - input.setAttribute("onclick", 'javascript: savePopupData("mapping", "", "")'); - interaction.appendChild(input); - var input = this.createInput("button", menuKey, "Bulk Edit"); - input.setAttribute("onclick", 'javascript: bulkEdit()'); - interaction.appendChild(input); - var input = this.createInput("search", "search", ""); - input.setAttribute("id", "searchMapping"); - input.setAttribute("placeholder", "Search"); - input.className = "search"; - input.setAttribute("oninput", 'javascript: searchInMapping()'); - interaction.appendChild(input); - break; - case "settings": - var input = this.createInput("button", menuKey, "Save"); - input.setAttribute("onclick", 'javascript: saveSettings();'); - interaction.appendChild(input); - var input = this.createInput("button", menuKey, "Backup"); - input.setAttribute("onclick", 'javascript: backup();'); - interaction.appendChild(input); - var input = this.createInput("button", menuKey, "Restore"); - input.setAttribute("onclick", 'javascript: restore();'); - interaction.appendChild(input); - var wrapper = document.createElement("DIV"); - wrapper.setAttribute("id", "box-wrapper"); - doc.appendChild(wrapper); - this.DivID = "content_settings"; - var settings = this.createDIV(); - wrapper.appendChild(settings); - showSettings(); - return; - break; - case "log": - var input = this.createInput("button", menuKey, "Reset Logs"); - input.setAttribute("onclick", 'javascript: resetLogs();'); - interaction.appendChild(input); - var wrapper = document.createElement("DIV"); - wrapper.setAttribute("id", "box-wrapper"); - doc.appendChild(wrapper); - this.DivID = "content_log"; - var logs = this.createDIV(); - wrapper.appendChild(logs); - showLogs(true); - return; - break; - case "logout": - location.reload(); - document.cookie = "Token= ; expires = Thu, 01 Jan 1970 00:00:00 GMT"; - break; - default: - console.log("Show content (menuKey):", menuKey); - break; - } - // Tabelle erstellen (falls benötigt) - var tableHeader = menuItems[this.menuID].tableHeader; - if (tableHeader.length > 0) { - var wrapper = document.createElement("DIV"); - doc.appendChild(wrapper); - wrapper.setAttribute("id", "box-wrapper"); - var table = this.createTABLE(); - wrapper.appendChild(table); - var header = this.createTableRow(); - table.appendChild(header); - // Kopfzeile der Tablle - tableHeader.forEach(element => { - var cell = new Cell(); - cell.child = true; - cell.childType = "P"; - cell.value = element; - if (element == "BULK") { - cell.childType = "BULK_HEAD"; - cell.value = false; - } - if (menuKey == "mapping") { - if (element == "Ch. No.") { - cell.onclick = true; - cell.onclickFunktion = "javascript: sortTable(1);"; - cell.tdClassName = "sortThis"; - } - if (element == "Channel Name") { - cell.onclick = true; - cell.onclickFunktion = "javascript: sortTable(3);"; - } - if (element == "Playlist") { - cell.onclick = true; - cell.onclickFunktion = "javascript: sortTable(4);"; - } - if (element == "Group Title") { - cell.onclick = true; - cell.onclickFunktion = "javascript: sortTable(5);"; - } - } - header.appendChild(cell.createCell()); - }); - table.appendChild(header); - // Inhalt der Tabelle - var rows = this.createTableContent(menuKey); - rows.forEach(tr => { - table.appendChild(tr); - }); - } - switch (menuKey) { - case "mapping": - sortTable(1); - break; - case "filter": - showPreview(true); - sortTable(0); - break; - default: - COLUMN_TO_SORT = -1; - sortTable(0); - break; - } - showElement("loading", false); - } -} var SHELL_LAYOUT_READY = false; function setLayoutMenuState(open) { if (document.body == null) { @@ -1110,6 +517,28 @@ function setClientInfoValue(key, value) { } element.textContent = textValue; } +// menuItemVisible reports whether a menu entry applies to the current server state. +function menuItemVisible(item) { + switch (item.menuKey) { + case "users": + case "logout": + return SERVER["settings"]["authentication.web"] == true; + case "mapping": + case "xmltv": + return SERVER["clientInfo"]["epgSource"] == "XEPG"; + default: + return true; + } +} +// renderedMenuKeys lists the menu entries currently in the document, in order. +function renderedMenuKeys(menu) { + var keys = []; + var items = menu.getElementsByTagName("LI"); + for (var i = 0; i < items.length; i++) { + keys.push(items[i].getAttribute("data-menu")); + } + return keys; +} function createLayout() { // Client Info var obj = SERVER["clientInfo"]; @@ -1118,37 +547,32 @@ function createLayout() { setClientInfoValue(keys[i], obj[keys[i]]); } renderStatusCards(); - if (!document.getElementById("main-menu")) { + var menu = document.getElementById("main-menu"); + if (menu == null) { return; } - // Menü erstellen - document.getElementById("main-menu").innerHTML = ""; + // Menu: rebuilt only when the set of visible entries changes, so that + // keyboard focus inside the list survives routine server responses. + var visibleKeys = []; for (let i = 0; i < menuItems.length; i++) { menuItems[i].id = i; - switch (menuItems[i]["menuKey"]) { - case "users": - case "logout": - if (SERVER["settings"]["authentication.web"] == true) { - menuItems[i].createItem(); - } - break; - case "mapping": - case "xmltv": - if (SERVER["clientInfo"]["epgSource"] == "XEPG") { - menuItems[i].createItem(); - } - break; - default: + if (menuItemVisible(menuItems[i])) { + visibleKeys.push(menuItems[i].menuKey); + } + } + if (visibleKeys.join(",") != renderedMenuKeys(menu).join(",")) { + menu.innerHTML = ""; + for (let i = 0; i < menuItems.length; i++) { + if (menuItemVisible(menuItems[i])) { menuItems[i].createItem(); - break; + } } } if (ACTIVE_MENU_ID.length > 0 && document.getElementById(ACTIVE_MENU_ID)) { setActiveMenu(ACTIVE_MENU_ID); } var content = document.getElementById("content"); - var menu = document.getElementById("main-menu"); - if (ACTIVE_MENU_ID.length == 0 && content != null && menu != null) { + if (ACTIVE_MENU_ID.length == 0 && content != null) { if (content.innerHTML.replace(/\\s/g, "").length == 0) { var firstItem = menu.getElementsByTagName("LI")[0]; if (firstItem != undefined) { @@ -1158,6 +582,600 @@ function createLayout() { } return; } +class Content { + constructor() { + this.DocumentID = "content"; + this.TableID = "content_table"; + this.headerClass = "content_table_header"; + this.interactionID = "content-interaction"; + } + createHeadline(value) { + var element = document.createElement("H3"); + element.textContent = value; + return element; + } + createHR() { + var element = document.createElement("HR"); + return element; + } + createInteraction() { + var element = document.createElement("DIV"); + element.setAttribute("id", this.interactionID); + return element; + } + createDIV() { + var element = document.createElement("DIV"); + element.id = this.DivID; + return element; + } + createTABLE() { + var element = document.createElement("TABLE"); + element.id = this.TableID; + return element; + } + createTableRow() { + var element = document.createElement("TR"); + element.className = this.headerClass; + return element; + } + createTableContent(menuKey) { + var data = new Object(); + var rows = new Array(); + switch (menuKey) { + case "playlist": + var fileTypes = new Array("m3u", "hdhr"); + fileTypes.forEach(fileType => { + data = SERVER["settings"]["files"][fileType]; + var keys = getObjKeys(data); + keys.forEach(key => { + var tr = document.createElement("TR"); + tr.id = key; + tr.addEventListener("click", function () { openPopUp(fileType, tr); }); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["name"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + if (SERVER["settings"]["buffer"] != "-") { + cell.value = data[key]["tuner"]; + } + else { + cell.value = "-"; + } + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["last.update"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["provider.availability"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["type"].toUpperCase(); + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["compatibility"]["streams"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["compatibility"]["group.title"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["compatibility"]["tvg.id"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["compatibility"]["stream.id"]; + tr.appendChild(cell.createCell()); + rows.push(tr); + }); + }); + break; + case "filter": + delete SERVER["settings"]["filter"][-1]; + data = SERVER["settings"]["filter"]; + var keys = getObjKeys(data); + keys.forEach(key => { + var tr = document.createElement("TR"); + tr.id = key; + tr.addEventListener("click", function () { openPopUp(data[key]["type"], tr); }); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["name"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + switch (data[key]["type"]) { + case "custom-filter": + cell.value = "Custom"; + break; + case "group-title": + cell.value = "Group"; + break; + case "regex-filter": + cell.value = "Regex"; + break; + default: + break; + } + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["filter"]; + tr.appendChild(cell.createCell()); + rows.push(tr); + }); + break; + case "xmltv": + var fileTypes = new Array("xmltv"); + fileTypes.forEach(fileType => { + data = SERVER["settings"]["files"][fileType]; + var keys = getObjKeys(data); + keys.forEach(key => { + var tr = document.createElement("TR"); + tr.id = key; + tr.addEventListener("click", function () { openPopUp(fileType, tr); }); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["name"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["last.update"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["provider.availability"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["compatibility"]["xmltv.channels"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["compatibility"]["xmltv.programs"]; + tr.appendChild(cell.createCell()); + rows.push(tr); + }); + }); + break; + case "users": + var fileTypes = new Array("users"); + fileTypes.forEach(fileType => { + data = SERVER[fileType]; + var keys = getObjKeys(data); + keys.forEach(key => { + var tr = document.createElement("TR"); + tr.id = key; + tr.addEventListener("click", function () { openPopUp(fileType, tr); }); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["data"]["username"]; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = "******"; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + if (data[key]["data"]["authentication.web"] == true) { + cell.value = "✓"; + } + else { + cell.value = "-"; + } + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + if (data[key]["data"]["authentication.pms"] == true) { + cell.value = "✓"; + } + else { + cell.value = "-"; + } + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + if (data[key]["data"]["authentication.m3u"] == true) { + cell.value = "✓"; + } + else { + cell.value = "-"; + } + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + if (data[key]["data"]["authentication.xml"] == true) { + cell.value = "✓"; + } + else { + cell.value = "-"; + } + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + if (data[key]["data"]["authentication.api"] == true) { + cell.value = "✓"; + } + else { + cell.value = "-"; + } + tr.appendChild(cell.createCell()); + rows.push(tr); + }); + }); + break; + case "mapping": + BULK_EDIT = false; + createSearchObj(); + checkUndo("epgMapping"); + console.log("MAPPING"); + data = SERVER["xepg"]["epgMapping"]; + var keys = getObjKeys(data); + keys.forEach(key => { + var tr = document.createElement("TR"); + tr.id = key; + switch (data[key]["x-active"]) { + case true: + tr.className = "activeEPG"; + break; + case false: + tr.className = "notActiveEPG"; + break; + } + // Bulk + var cell = new Cell(); + cell.child = true; + cell.childType = "BULK"; + cell.value = false; + tr.appendChild(cell.createCell()); + // Kanalnummer + var cell = new Cell(); + cell.child = true; + cell.childType = "INPUTCHANNEL"; + cell.value = data[key]["x-channelID"]; + tr.appendChild(cell.createCell()); + // Logo + var cell = new Cell(); + cell.child = true; + cell.childType = "IMG"; + cell.imageURL = data[key]["tvg-logo"]; + tr.appendChild(cell.createCell()); + // Kanalname + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.className = data[key]["x-category"]; + cell.value = data[key]["x-name"]; + tr.appendChild(cell.createCell()); + // Playlist + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + //cell.value = data[key]["_file.m3u.name"] + cell.value = getValueFromProviderFile(data[key]["_file.m3u.id"], "m3u", "name"); + tr.appendChild(cell.createCell()); + // Gruppe (group-title) + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = data[key]["x-group-title"]; + tr.appendChild(cell.createCell()); + // XMLTV Datei + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + if (data[key]["x-xmltv-file"] != "-") { + cell.value = getValueFromProviderFile(data[key]["x-xmltv-file"], "xmltv", "name"); + } + else { + cell.value = data[key]["x-xmltv-file"]; + } + tr.appendChild(cell.createCell()); + // XMLTV Kanal + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + //var value = str.substring(1, 4); + var value = data[key]["x-mapping"]; + if (value.length > 20) { + value = data[key]["x-mapping"].substring(0, 20) + "..."; + } + cell.value = value; + tr.appendChild(cell.createCell()); + var cell = new Cell(); + cell.child = true; + cell.childType = "EDIT"; + cell.value = "Edit"; + var editTd = cell.createCell(); + var editButton = editTd.firstChild; + editButton.addEventListener("click", function () { openPopUp("mapping", editButton); }); + editButton.setAttribute("id", key); + editButton.setAttribute("aria-label", "Edit " + data[key]["x-name"]); + tr.appendChild(editTd); + rows.push(tr); + }); + break; + case "settings": + alert(); + break; + default: + console.log("Table content (menuKey):", menuKey); + break; + } + return rows; + } +} +class Cell { + createCell() { + var td = document.createElement("TD"); + if (this.child == true) { + var element; + switch (this.childType) { + case "P": + element = document.createElement(this.childType); + element.textContent = this.value; + element.className = this.className; + break; + case "INPUT": + element = document.createElement(this.childType); + element.value = this.value; + element.type = "text"; + break; + case "INPUTCHANNEL": + element = document.createElement("INPUT"); + element.addEventListener("change", function () { changeChannelNumber(element); }); + element.value = this.value; + element.type = "text"; + break; + case "BULK": + element = document.createElement("INPUT"); + element.checked = this.value; + element.type = "checkbox"; + element.className = "bulk hideBulk"; + element.addEventListener("click", function (event) { + scheduleChannelRangeSelection(element, event); + }); + break; + case "BULK_HEAD": + element = document.createElement("INPUT"); + element.checked = this.value; + element.type = "checkbox"; + element.className = "bulk hideBulk"; + element.addEventListener("click", function () { selectAllChannels(); }); + break; + case "IMG": + element = document.createElement(this.childType); + element.setAttribute("src", this.imageURL); + if (this.imageURL != "") { + // A broken logo falls back to no image; once, so the empty src cannot loop. + element.addEventListener("error", function () { element.setAttribute("src", ""); }, { once: true }); + } + break; + case "EDIT": + element = document.createElement("INPUT"); + element.type = "button"; + element.value = this.value; + element.className = "mapping-edit-button"; + break; + } + td.appendChild(element); + } + else { + td.textContent = this.value; + } + if (this.onclick == true) { + td.addEventListener("click", this.onclickHandler); + td.className = "pointer"; + } + if (this.tdClassName != undefined) { + td.className = this.tdClassName; + } + return td; + } +} +class ShowContent extends Content { + constructor(menuID) { + super(); + this.menuID = menuID; + } + createInput(type, name, value) { + var input = document.createElement("INPUT"); + input.setAttribute("type", type); + input.setAttribute("name", name); + input.setAttribute("value", value); + return input; + } + show() { + COLUMN_TO_SORT = -1; + // Alten Inhalt löschen + var doc = document.getElementById(this.DocumentID); + doc.innerHTML = ""; + showPreview(false); + // Überschrift + var headline = menuItems[this.menuID].headline; + var menuKey = menuItems[this.menuID].menuKey; + var h = this.createHeadline(headline); + doc.appendChild(h); + var hr = this.createHR(); + doc.appendChild(hr); + // Interaktion + var div = this.createInteraction(); + doc.appendChild(div); + var interaction = document.getElementById(this.interactionID); + switch (menuKey) { + case "playlist": + var input = this.createInput("button", menuKey, "New"); + input.setAttribute("id", "-"); + input.addEventListener("click", function () { openPopUp("playlist"); }); + interaction.appendChild(input); + break; + case "filter": + var input = this.createInput("button", menuKey, "New"); + input.setAttribute("id", -1); + input.addEventListener("click", function (event) { openPopUp("filter", event.currentTarget); }); + interaction.appendChild(input); + break; + case "xmltv": + var input = this.createInput("button", menuKey, "New"); + input.setAttribute("id", "xmltv"); + input.addEventListener("click", function () { openPopUp("xmltv"); }); + interaction.appendChild(input); + break; + case "users": + var input = this.createInput("button", menuKey, "New"); + input.setAttribute("id", "users"); + input.addEventListener("click", function () { openPopUp("users"); }); + interaction.appendChild(input); + break; + case "mapping": + showElement("loading", true); + var input = this.createInput("button", menuKey, "Save"); + input.addEventListener("click", function () { savePopupData("mapping", "", false, 0); }); + interaction.appendChild(input); + var input = this.createInput("button", menuKey, "Bulk Edit"); + input.addEventListener("click", function () { bulkEdit(); }); + interaction.appendChild(input); + var input = this.createInput("search", "search", ""); + input.setAttribute("id", "searchMapping"); + input.setAttribute("placeholder", "Search"); + input.className = "search"; + input.addEventListener("input", function () { searchInMapping(); }); + interaction.appendChild(input); + break; + case "settings": + var input = this.createInput("button", menuKey, "Save"); + input.addEventListener("click", function () { saveSettings(); }); + interaction.appendChild(input); + var input = this.createInput("button", menuKey, "Backup"); + input.addEventListener("click", function () { backup(); }); + interaction.appendChild(input); + var input = this.createInput("button", menuKey, "Restore"); + input.addEventListener("click", function () { restore(); }); + interaction.appendChild(input); + var wrapper = document.createElement("DIV"); + wrapper.setAttribute("id", "box-wrapper"); + doc.appendChild(wrapper); + this.DivID = "content_settings"; + var settings = this.createDIV(); + wrapper.appendChild(settings); + showSettings(); + return; + break; + case "log": + var input = this.createInput("button", menuKey, "Reset Logs"); + input.addEventListener("click", function () { resetLogs(); }); + interaction.appendChild(input); + var wrapper = document.createElement("DIV"); + wrapper.setAttribute("id", "box-wrapper"); + doc.appendChild(wrapper); + this.DivID = "content_log"; + var logs = this.createDIV(); + wrapper.appendChild(logs); + showLogs(true); + return; + break; + case "logout": + location.reload(); + document.cookie = "Token= ; expires = Thu, 01 Jan 1970 00:00:00 GMT"; + break; + default: + console.log("Show content (menuKey):", menuKey); + break; + } + // Tabelle erstellen (falls benötigt) + var tableHeader = menuItems[this.menuID].tableHeader; + if (tableHeader.length > 0) { + var wrapper = document.createElement("DIV"); + doc.appendChild(wrapper); + wrapper.setAttribute("id", "box-wrapper"); + var table = this.createTABLE(); + wrapper.appendChild(table); + var header = this.createTableRow(); + table.appendChild(header); + // Kopfzeile der Tablle + tableHeader.forEach(element => { + var cell = new Cell(); + cell.child = true; + cell.childType = "P"; + cell.value = element; + if (element == "BULK") { + cell.childType = "BULK_HEAD"; + cell.value = false; + } + if (menuKey == "mapping") { + if (element == "Ch. No.") { + cell.onclick = true; + cell.onclickHandler = function () { sortTable(1); }; + cell.tdClassName = "sortThis"; + } + if (element == "Channel Name") { + cell.onclick = true; + cell.onclickHandler = function () { sortTable(3); }; + } + if (element == "Playlist") { + cell.onclick = true; + cell.onclickHandler = function () { sortTable(4); }; + } + if (element == "Group Title") { + cell.onclick = true; + cell.onclickHandler = function () { sortTable(5); }; + } + } + header.appendChild(cell.createCell()); + }); + table.appendChild(header); + // Inhalt der Tabelle + var rows = this.createTableContent(menuKey); + rows.forEach(tr => { + table.appendChild(tr); + }); + } + switch (menuKey) { + case "mapping": + sortTable(1); + break; + case "filter": + showPreview(true); + sortTable(0); + break; + default: + COLUMN_TO_SORT = -1; + sortTable(0); + break; + } + showElement("loading", false); + } +} function openThisMenu(element) { var id = element.id; var content = new ShowContent(id); @@ -1275,6 +1293,33 @@ class PopupContent extends PopupWindow { interaction.appendChild(element); } } +// Buffer choices a playlist can make; "default" means the global setting. +const PLAYLIST_BUFFER_OPTIONS = [ + { text: "Default (global setting)", value: "default" }, + { text: "No buffer (pass the URL through)", value: "-" }, + { text: "xTeVe", value: "xteve" }, + { text: "FFmpeg", value: "ffmpeg" }, + { text: "VLC", value: "vlc" } +]; +function createBufferSelect(content, data) { + var text = PLAYLIST_BUFFER_OPTIONS.map(function (o) { return o.text; }); + var values = PLAYLIST_BUFFER_OPTIONS.map(function (o) { return o.value; }); + var current = data["buffer"]; + if (values.indexOf(current) == -1) { + current = "default"; + } + return content.createSelect(text, values, current, "buffer"); +} +// playlistBufferActive: is a buffer in use for this playlist (its own choice, +// or the global setting when it has none)? Decides whether the tuner count +// can be edited. +function playlistBufferActive(data) { + var own = data["buffer"]; + if (own != undefined && own != "default" && own != "") { + return own != "-"; + } + return SERVER["settings"]["buffer"] != "-"; +} function openPopUp(dataType, element) { var data = new Object(); var id; @@ -1295,6 +1340,13 @@ function openPopUp(dataType, element) { data = getLocalData("filter", id); data["type"] = "custom-filter"; break; + case "regex-filter": + if (id == undefined) { + id = -1; + } + data = getLocalData("filter", id); + data["type"] = "regex-filter"; + break; default: data["id.provider"] = "-"; data["type"] = dataType; @@ -1313,20 +1365,20 @@ function openPopUp(dataType, element) { content.createHeadline("Playlist type"); // Type var text = ["M3U", "HDHomeRun"]; - var values = ["javascript: openPopUp('m3u')", "javascript: openPopUp('hdhr')"]; + var values = ["m3u", "hdhr"]; var select = content.createSelect(text, values, "", "type"); select.setAttribute("id", "type"); - select.setAttribute("onchange", 'javascript: changeButtonAction(this, "next", "onclick")'); // changeButtonAction content.appendRow("Type", select); + const typeSelect = select; // Interaktion content.createInteraction(); // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Weiter var input = content.createInput("button", "next", "Next"); - input.setAttribute("onclick", 'javascript: openPopUp("m3u")'); + input.addEventListener("click", function () { openPopUp(typeSelect.value); }); input.setAttribute("id", 'next'); content.addInteraction(input); break; @@ -1347,8 +1399,11 @@ function openPopUp(dataType, element) { var input = content.createInput("text", dbKey, data[dbKey]); input.setAttribute("placeholder", "File path or URL of the M3U"); content.appendRow("M3U File", input); + // Buffer (per playlist override of the global setting) + var bufferSelect = createBufferSelect(content, data); + content.appendRow("Buffer", bufferSelect); // Tuner - if (SERVER["settings"]["buffer"] != "-") { + if (playlistBufferActive(data)) { var text = new Array(); var values = new Array(); for (var i = 1; i <= 100; i++) { @@ -1357,7 +1412,6 @@ function openPopUp(dataType, element) { } var dbKey = "tuner"; var select = content.createSelect(text, values, data[dbKey], dbKey); - select.setAttribute("onfocus", "javascript: return;"); content.appendRow("Tuner / Streams", select); } else { @@ -1377,27 +1431,27 @@ function openPopUp(dataType, element) { if (data["id.provider"] != "-") { var input = content.createInput("button", "delete", "Delete"); input.className = "delete"; - input.setAttribute('onclick', 'javascript: savePopupData("m3u", "' + id + '", true, 0)'); + input.addEventListener("click", function () { savePopupData("m3u", String(id), true, 0); }); content.addInteraction(input); } else { var input = content.createInput("button", "back", "Back"); - input.setAttribute("onclick", 'javascript: openPopUp("playlist")'); + input.addEventListener("click", function () { openPopUp("playlist"); }); content.addInteraction(input); } // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Aktualisieren if (data["id.provider"] != "-") { var input = content.createInput("button", "update", "Update"); - input.setAttribute('onclick', 'javascript: savePopupData("m3u", "' + id + '", false, 1)'); + input.addEventListener("click", function () { savePopupData("m3u", String(id), false, 1); }); content.addInteraction(input); } // Speichern var input = content.createInput("button", "save", "Save"); - input.setAttribute('onclick', 'javascript: savePopupData("m3u", "' + id + '", false, 0)'); + input.addEventListener("click", function () { savePopupData("m3u", String(id), false, 0); }); content.addInteraction(input); break; case "hdhr": @@ -1417,8 +1471,11 @@ function openPopUp(dataType, element) { var input = content.createInput("text", dbKey, data[dbKey]); input.setAttribute("placeholder", "IP address and port (192.168.1.10:5004)"); content.appendRow("HDHomeRun IP", input); + // Buffer (per playlist override of the global setting) + var bufferSelect = createBufferSelect(content, data); + content.appendRow("Buffer", bufferSelect); // Tuner - if (SERVER["settings"]["buffer"] != "-") { + if (playlistBufferActive(data)) { var text = new Array(); var values = new Array(); for (var i = 1; i <= 100; i++) { @@ -1427,7 +1484,6 @@ function openPopUp(dataType, element) { } var dbKey = "tuner"; var select = content.createSelect(text, values, data[dbKey], dbKey); - select.setAttribute("onfocus", "javascript: return;"); content.appendRow("Tuner / Streams", select); } else { @@ -1446,54 +1502,55 @@ function openPopUp(dataType, element) { // Löschen if (data["id.provider"] != "-") { var input = content.createInput("button", "delete", "Delete"); - input.setAttribute('onclick', 'javascript: savePopupData("hdhr", "' + id + '", true, 0)'); + input.addEventListener("click", function () { savePopupData("hdhr", String(id), true, 0); }); input.className = "delete"; content.addInteraction(input); } else { var input = content.createInput("button", "back", "Back"); - input.setAttribute("onclick", 'javascript: openPopUp("playlist")'); + input.addEventListener("click", function () { openPopUp("playlist"); }); content.addInteraction(input); } // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Aktualisieren if (data["id.provider"] != "-") { var input = content.createInput("button", "update", "Update"); - input.setAttribute('onclick', 'javascript: savePopupData("hdhr", "' + id + '", false, 1)'); + input.addEventListener("click", function () { savePopupData("hdhr", String(id), false, 1); }); content.addInteraction(input); } // Speichern var input = content.createInput("button", "save", "Save"); - input.setAttribute('onclick', 'javascript: savePopupData("hdhr", "' + id + '", false, 0)'); + input.addEventListener("click", function () { savePopupData("hdhr", String(id), false, 0); }); content.addInteraction(input); break; case "filter": content.createHeadline(dataType); // Type var dbKey = "type"; - var text = ["M3U: " + "Group Title", "xTeVe: " + "Custom Filter"]; - var values = ["javascript: openPopUp('group-title')", "javascript: openPopUp('custom-filter')"]; - var select = content.createSelect(text, values, "javascript: openPopUp('group-title')", dbKey); + var text = ["M3U: " + "Group Title", "xTeVe: " + "Custom Filter", "xTeVe: " + "Regular Expression"]; + var values = ["group-title", "custom-filter", "regex-filter"]; + var select = content.createSelect(text, values, "group-title", dbKey); select.setAttribute("id", id); - select.setAttribute("onchange", 'javascript: changeButtonAction(this, "next", "onclick");'); // changeButtonAction content.appendRow("Type", select); + const filterTypeSelect = select; // Interaktion content.createInteraction(); // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Weiter var input = content.createInput("button", "next", "Next"); - input.setAttribute("onclick", 'javascript: openPopUp("group-title")'); + input.addEventListener("click", function () { openPopUp(filterTypeSelect.value); }); input.setAttribute("id", 'next'); content.addInteraction(input); break; case "custom-filter": case "group-title": + case "regex-filter": switch (dataType) { case "custom-filter": content.createHeadline("Custom"); @@ -1501,6 +1558,9 @@ function openPopUp(dataType, element) { case "group-title": content.createHeadline("Group"); break; + case "regex-filter": + content.createHeadline("Regular Expression"); + break; } // Name var dbKey = "name"; @@ -1530,6 +1590,19 @@ function openPopUp(dataType, element) { input.setAttribute("placeholder", "Sport {HD} !{ES,IT}"); content.appendRow("Filter Rule", input); break; + case "regex-filter": + // Groß- Kleinschreibung beachten + var dbKey = "caseSensitive"; + var input = content.createCheckbox(dbKey); + input.checked = data[dbKey]; + content.appendRow("Case Sensitive", input); + // Regulärer Ausdruck + var dbKey = "filter"; + var input = content.createInput("text", dbKey, data[dbKey]); + input.setAttribute("placeholder", "^(BBC|ITV) \\d+$"); + content.appendRow("Regular Expression", input); + content.description("Go regular expression, tried against the channel name, then the group title, then the raw M3U attributes. Case-insensitive unless Case Sensitive is checked. Invalid patterns are rejected on save."); + break; case "group-title": //alert(dbKey + " " + filterType) // Filter basierend auf den Gruppen in der M3U @@ -1538,7 +1611,7 @@ function openPopUp(dataType, element) { var text = groupsM3U["text"]; var values = groupsM3U["value"]; var select = content.createSelect(text, values, data[dbKey], dbKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); + markChangedOnChange(select); content.appendRow("Group Title", select); content.description("Select a M3U group. (Counter)
Changing the group title in the M3U invalidates the filter.", true); // Groß- Kleinschreibung beachten @@ -1564,16 +1637,16 @@ function openPopUp(dataType, element) { content.createInteraction(); // Löschen var input = content.createInput("button", "delete", "Delete"); - input.setAttribute('onclick', 'javascript: savePopupData("filter", "' + id + '", true, 0)'); + input.addEventListener("click", function () { savePopupData("filter", String(id), true, 0); }); input.className = "delete"; content.addInteraction(input); // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Speichern var input = content.createInput("button", "save", "Save"); - input.setAttribute('onclick', 'javascript: savePopupData("filter", "' + id + '", false, 0)'); + input.addEventListener("click", function () { savePopupData("filter", String(id), false, 0); }); content.addInteraction(input); break; case "xmltv": @@ -1598,23 +1671,23 @@ function openPopUp(dataType, element) { // Löschen if (data["id.provider"] != "-") { var input = content.createInput("button", "delete", "Delete"); - input.setAttribute('onclick', 'javascript: savePopupData("xmltv", "' + id + '", true, 0)'); + input.addEventListener("click", function () { savePopupData("xmltv", String(id), true, 0); }); input.className = "delete"; content.addInteraction(input); } // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Aktualisieren if (data["id.provider"] != "-") { var input = content.createInput("button", "update", "Update"); - input.setAttribute('onclick', 'javascript: savePopupData("xmltv", "' + id + '", false, 1)'); + input.addEventListener("click", function () { savePopupData("xmltv", String(id), false, 1); }); content.addInteraction(input); } // Speichern var input = content.createInput("button", "save", "Save"); - input.setAttribute('onclick', 'javascript: savePopupData("xmltv", "' + id + '", false, 0)'); + input.addEventListener("click", function () { savePopupData("xmltv", String(id), false, 0); }); content.addInteraction(input); break; case "users": @@ -1639,7 +1712,7 @@ function openPopUp(dataType, element) { var input = content.createCheckbox(dbKey); input.checked = data[dbKey]; if (data["defaultUser"] == true) { - input.setAttribute("onclick", "javascript: return false"); + input.addEventListener("click", function (event) { event.preventDefault(); }); } content.appendRow("Web Access", input); // Berechtigung PMS @@ -1668,16 +1741,16 @@ function openPopUp(dataType, element) { if (data["defaultUser"] != true && id != "-") { var input = content.createInput("button", "delete", "Delete"); input.className = "delete"; - input.setAttribute('onclick', 'javascript: savePopupData("' + dataType + '", "' + id + '", true, 0)'); + input.addEventListener("click", function () { savePopupData(dataType, String(id), true, 0); }); content.addInteraction(input); } // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Speichern var input = content.createInput("button", "save", "Save"); - input.setAttribute("onclick", 'javascript: savePopupData("' + dataType + '", "' + id + '", "false");'); + input.addEventListener("click", function () { savePopupData(dataType, String(id), false, 0); }); content.addInteraction(input); break; case "mapping": @@ -1687,13 +1760,13 @@ function openPopUp(dataType, element) { var input = content.createCheckbox(dbKey); input.checked = data[dbKey]; input.id = "active"; - //input.setAttribute("onchange", "javascript: this.className = 'changed'") - input.setAttribute("onchange", "javascript: toggleChannelStatus('" + id + "', this)"); + //markChangedOnChange(input) + input.addEventListener("change", function () { toggleChannelStatus(id); }); content.appendRow("Active", input); // Kanalname var dbKey = "x-name"; var input = content.createInput("text", dbKey, data[dbKey]); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); + markChangedOnChange(input); if (BULK_EDIT == true) { input.style.border = "solid 1px red"; input.setAttribute("readonly", "true"); @@ -1704,14 +1777,14 @@ function openPopUp(dataType, element) { var dbKey = "x-description"; var input = content.createInput("text", dbKey, data[dbKey]); input.setAttribute("placeholder", "Used by the Dummy as an XML description"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); + markChangedOnChange(input); content.appendRow("Channel Description", input); // Aktualisierung des Kanalnamens if (data.hasOwnProperty("_uuid.key")) { if (data["_uuid.key"] != "") { var dbKey = "x-update-channel-name"; var input = content.createCheckbox(dbKey); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); + markChangedOnChange(input); input.checked = data[dbKey]; content.appendRow("Update Channel Name", input); } @@ -1719,7 +1792,7 @@ function openPopUp(dataType, element) { // Logo URL (Kanal) var dbKey = "tvg-logo"; var input = content.createInput("text", dbKey, data[dbKey]); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); + markChangedOnChange(input); input.setAttribute("id", "channel-icon"); content.appendRow("Logo URL", input); // Aktualisierung des Kanallogos @@ -1727,19 +1800,19 @@ function openPopUp(dataType, element) { var input = content.createCheckbox(dbKey); input.checked = data[dbKey]; input.setAttribute("id", "update-icon"); - input.setAttribute("onchange", "javascript: this.className = 'changed'; changeChannelLogo('" + id + "');"); + markChangedOnChange(input, function () { changeChannelLogo(id); }); content.appendRow("Update Channel Logo", input); // Erweitern der EPG Kategorie var dbKey = "x-category"; var text = ["-", "Kids (Emby only)", "News", "Movie", "Series", "Sports"]; var values = ["", "Kids", "News", "Movie", "Series", "Sports"]; var select = content.createSelect(text, values, data[dbKey], dbKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); + markChangedOnChange(select); content.appendRow("EPG Category", select); // M3U Gruppentitel var dbKey = "x-group-title"; var input = content.createInput("text", dbKey, data[dbKey]); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); + markChangedOnChange(input); content.appendRow("Group Title (xteve.m3u)", input); if (data["group-title"] != undefined) { content.description(data["group-title"]); @@ -1751,7 +1824,7 @@ function openPopUp(dataType, element) { var select = xmltv.getFiles(data[dbKey]); select.setAttribute("name", dbKey); select.setAttribute("id", "popup-xmltv"); - select.setAttribute("onchange", "javascript: this.className = 'changed'; setXmltvChannel('" + id + "',this);"); + markChangedOnChange(select, function (element) { setXmltvChannel(id, element); }); content.appendRow("XMLTV File", select); var file = data[dbKey]; // XMLTV Mapping @@ -1760,18 +1833,18 @@ function openPopUp(dataType, element) { var select = xmltv.getPrograms(file, data[dbKey]); select.setAttribute("name", dbKey); select.setAttribute("id", "popup-mapping"); - select.setAttribute("onchange", "javascript: this.className = 'changed'; checkXmltvChannel('" + id + "',this,'" + xmlFile + "');"); + markChangedOnChange(select, function (element) { checkXmltvChannel(id, element, xmlFile); }); sortSelect(select); content.appendRow("XMLTV Channel", select); // Interaktion content.createInteraction(); // Logo hochladen var input = content.createInput("button", "cancel", "Upload Logo"); - input.setAttribute("onclick", 'javascript: uploadLogo();'); + input.addEventListener("click", function () { uploadLogo(); }); content.addInteraction(input); // Abbrechen var input = content.createInput("button", "cancel", "Cancel"); - input.setAttribute("onclick", 'javascript: showElement("popup", false);'); + input.addEventListener("click", function () { showElement("popup", false); }); content.addInteraction(input); // Fertig var ids = new Array(); @@ -1780,7 +1853,7 @@ function openPopUp(dataType, element) { ids.push(id); } var input = content.createInput("button", "save", "Done"); - input.setAttribute("onclick", 'javascript: donePopupData("' + dataType + '", "' + ids + '", "false");'); + input.addEventListener("click", function () { donePopupData(dataType, ids.join(",")); }); content.addInteraction(input); break; default: @@ -1788,162 +1861,6 @@ function openPopUp(dataType, element) { } showPopUpElement('popup-custom'); } -class XMLTVFile { - getFiles(set) { - var fileIDs = getObjKeys(SERVER["xepg"]["xmltvMap"]); - var values = new Array("-"); - var text = new Array("-"); - for (let i = 0; i < fileIDs.length; i++) { - if (fileIDs[i] != "xTeVe Dummy") { - values.push(getValueFromProviderFile(fileIDs[i], "xmltv", "file.xteve")); - text.push(getValueFromProviderFile(fileIDs[i], "xmltv", "name")); - } - else { - values.push(fileIDs[i]); - text.push(fileIDs[i]); - } - } - var select = document.createElement("SELECT"); - for (let i = 0; i < text.length; i++) { - var option = document.createElement("OPTION"); - option.setAttribute("value", values[i]); - option.innerText = text[i]; - select.appendChild(option); - } - if (set != "") { - select.value = set; - } - return select; - } - getPrograms(file, set) { - //var fileIDs:string[] = getObjKeys(SERVER["xepg"]["xmltvMap"]) - var values = getObjKeys(SERVER["xepg"]["xmltvMap"][file]); - var text = new Array(); - var displayName; - for (let i = 0; i < values.length; i++) { - if (SERVER["xepg"]["xmltvMap"][file][values[i]].hasOwnProperty('display-name') == true) { - displayName = SERVER["xepg"]["xmltvMap"][file][values[i]]["display-name"]; - } - else { - displayName = "-"; - } - text[i] = displayName + " (" + values[i] + ")"; - } - text.unshift("-"); - values.unshift("-"); - var select = document.createElement("SELECT"); - for (let i = 0; i < text.length; i++) { - var option = document.createElement("OPTION"); - option.setAttribute("value", values[i]); - option.innerText = text[i]; - select.appendChild(option); - } - if (set != "") { - select.value = set; - } - if (select.value != set) { - select.value = "-"; - } - return select; - } -} -function getValueFromProviderFile(file, fileType, key) { - if (file == "xTeVe Dummy") { - return file; - } - var fileID; - var indicator = file.charAt(0); - switch (indicator) { - case "M": - fileType = "m3u"; - fileID = file; - break; - case "H": - fileType = "hdhr"; - fileID = file; - break; - case "X": - fileType = "xmltv"; - fileID = file.substring(0, file.lastIndexOf('.')); - break; - } - if (SERVER["settings"]["files"][fileType].hasOwnProperty(fileID) == true) { - var data = SERVER["settings"]["files"][fileType][fileID]; - return data[key]; - } - return; -} -function setXmltvChannel(id, element) { - var xmltv = new XMLTVFile(); - var xmlFile = element.value; - var tvgId = SERVER["xepg"]["epgMapping"][id]["tvg-id"]; - var td = document.getElementById("popup-mapping").parentElement; - td.innerHTML = ""; - var select = xmltv.getPrograms(element.value, tvgId); - select.setAttribute("name", "x-mapping"); - select.setAttribute("id", "popup-mapping"); - select.setAttribute("onchange", "javascript: this.className = 'changed'; checkXmltvChannel('" + id + "',this,'" + xmlFile + "');"); - select.className = "changed"; - sortSelect(select); - td.appendChild(select); - checkXmltvChannel(id, select, xmlFile); -} -function checkXmltvChannel(id, element, xmlFile) { - var value = element.value; - var bool; - var checkbox = document.getElementById('active'); - var channel = SERVER["xepg"]["epgMapping"][id]; - var updateLogo; - if (value == "-") { - bool = false; - } - else { - bool = true; - } - checkbox.checked = bool; - checkbox.className = "changed"; - console.log(xmlFile); - // Kanallogo aktualisieren - /* - updateLogo = (document.getElementById("update-icon") as HTMLInputElement).checked - console.log(updateLogo); - */ - if (xmlFile != "xTeVe Dummy" && bool == true) { - //(document.getElementById("update-icon") as HTMLInputElement).checked = true; - //(document.getElementById("update-icon") as HTMLInputElement).className = "changed"; - console.log("ID", id); - changeChannelLogo(id); - return; - } - if (xmlFile == "xTeVe Dummy") { - document.getElementById("update-icon").checked = false; - document.getElementById("update-icon").className = "changed"; - } - return; -} -function changeChannelLogo(id) { - var updateLogo; - var channel = SERVER["xepg"]["epgMapping"][id]; - var f = document.getElementById("popup-xmltv"); - var xmltvFile = f.options[f.selectedIndex].value; - var m = document.getElementById("popup-mapping"); - var xMapping = m.options[m.selectedIndex].value; - var xmltvLogo = SERVER["xepg"]["xmltvMap"][xmltvFile][xMapping]["icon"]; - updateLogo = document.getElementById("update-icon").checked; - if (updateLogo == true && xmltvFile != "xTeVe Dummy") { - if (SERVER["xepg"]["xmltvMap"][xmltvFile].hasOwnProperty(xMapping)) { - var logo = xmltvLogo; - } - else { - logo = channel["tvg-logo"]; - } - var logoInput = document.getElementById("channel-icon"); - logoInput.value = logo; - if (BULK_EDIT == false) { - logoInput.className = "changed"; - } - } -} function savePopupData(dataType, id, remove, option) { if (dataType == "mapping") { var data = new Object(); @@ -2199,6 +2116,253 @@ function showPreview(element) { div.className = "visible"; return; } +class XMLTVFile { + getFiles(set) { + var fileIDs = getObjKeys(SERVER["xepg"]["xmltvMap"]); + var values = new Array("-"); + var text = new Array("-"); + for (let i = 0; i < fileIDs.length; i++) { + if (fileIDs[i] != "xTeVe Dummy") { + values.push(getValueFromProviderFile(fileIDs[i], "xmltv", "file.xteve")); + text.push(getValueFromProviderFile(fileIDs[i], "xmltv", "name")); + } + else { + values.push(fileIDs[i]); + text.push(fileIDs[i]); + } + } + var select = document.createElement("SELECT"); + for (let i = 0; i < text.length; i++) { + var option = document.createElement("OPTION"); + option.setAttribute("value", values[i]); + option.innerText = text[i]; + select.appendChild(option); + } + if (set != "") { + select.value = set; + } + return select; + } + getPrograms(file, set) { + //var fileIDs:string[] = getObjKeys(SERVER["xepg"]["xmltvMap"]) + var values = getObjKeys(SERVER["xepg"]["xmltvMap"][file]); + var text = new Array(); + var displayName; + for (let i = 0; i < values.length; i++) { + if (SERVER["xepg"]["xmltvMap"][file][values[i]].hasOwnProperty('display-name') == true) { + displayName = SERVER["xepg"]["xmltvMap"][file][values[i]]["display-name"]; + } + else { + displayName = "-"; + } + text[i] = displayName + " (" + values[i] + ")"; + } + text.unshift("-"); + values.unshift("-"); + var select = document.createElement("SELECT"); + for (let i = 0; i < text.length; i++) { + var option = document.createElement("OPTION"); + option.setAttribute("value", values[i]); + option.innerText = text[i]; + select.appendChild(option); + } + if (set != "") { + select.value = set; + } + if (select.value != set) { + select.value = "-"; + } + return select; + } +} +function getValueFromProviderFile(file, fileType, key) { + if (file == "xTeVe Dummy") { + return file; + } + var fileID; + var indicator = file.charAt(0); + switch (indicator) { + case "M": + fileType = "m3u"; + fileID = file; + break; + case "H": + fileType = "hdhr"; + fileID = file; + break; + case "X": + fileType = "xmltv"; + fileID = file.substring(0, file.lastIndexOf('.')); + break; + } + if (SERVER["settings"]["files"][fileType].hasOwnProperty(fileID) == true) { + var data = SERVER["settings"]["files"][fileType][fileID]; + return data[key]; + } + return; +} +function setXmltvChannel(id, element) { + var xmltv = new XMLTVFile(); + var xmlFile = element.value; + var tvgId = SERVER["xepg"]["epgMapping"][id]["tvg-id"]; + var td = document.getElementById("popup-mapping").parentElement; + td.innerHTML = ""; + var select = xmltv.getPrograms(element.value, tvgId); + select.setAttribute("name", "x-mapping"); + select.setAttribute("id", "popup-mapping"); + markChangedOnChange(select, function (element) { checkXmltvChannel(id, element, xmlFile); }); + select.className = "changed"; + sortSelect(select); + td.appendChild(select); + checkXmltvChannel(id, select, xmlFile); +} +function checkXmltvChannel(id, element, xmlFile) { + var value = element.value; + var bool; + var checkbox = document.getElementById('active'); + var channel = SERVER["xepg"]["epgMapping"][id]; + var updateLogo; + if (value == "-") { + bool = false; + } + else { + bool = true; + } + checkbox.checked = bool; + checkbox.className = "changed"; + console.log(xmlFile); + // Kanallogo aktualisieren + /* + updateLogo = (document.getElementById("update-icon") as HTMLInputElement).checked + console.log(updateLogo); + */ + if (xmlFile != "xTeVe Dummy" && bool == true) { + //(document.getElementById("update-icon") as HTMLInputElement).checked = true; + //(document.getElementById("update-icon") as HTMLInputElement).className = "changed"; + console.log("ID", id); + changeChannelLogo(id); + return; + } + if (xmlFile == "xTeVe Dummy") { + document.getElementById("update-icon").checked = false; + document.getElementById("update-icon").className = "changed"; + } + return; +} +function changeChannelLogo(id) { + var updateLogo; + var channel = SERVER["xepg"]["epgMapping"][id]; + var f = document.getElementById("popup-xmltv"); + var xmltvFile = f.options[f.selectedIndex].value; + var m = document.getElementById("popup-mapping"); + var xMapping = m.options[m.selectedIndex].value; + var xmltvLogo = SERVER["xepg"]["xmltvMap"][xmltvFile][xMapping]["icon"]; + updateLogo = document.getElementById("update-icon").checked; + if (updateLogo == true && xmltvFile != "xTeVe Dummy") { + if (SERVER["xepg"]["xmltvMap"][xmltvFile].hasOwnProperty(xMapping)) { + var logo = xmltvLogo; + } + else { + logo = channel["tvg-logo"]; + } + var logoInput = document.getElementById("channel-icon"); + logoInput.value = logo; + if (BULK_EDIT == false) { + logoInput.className = "changed"; + } + } +} +function settingsOptions(text, values) { + var options = []; + for (var i = 0; i < text.length; i++) { + options.push({ text: text[i], value: values[i] }); + } + return options; +} +function settingsNumberOptions(from, to) { + var options = []; + for (var i = from; i <= to; i++) { + options.push({ text: i.toString(), value: i.toString() }); + } + return options; +} +const SETTINGS_FIELDS = [ + // Text inputs + { key: "update", title: "Schedule for updating (Playlist, XMLTV, Backup)", type: "text", placeholder: "0000,1000,2000", + description: "Time in 24 hour format (0800 = 8:00 am). More times can be entered comma separated. Leave this field empty if no updates are to be carried out." }, + { key: "backup.path", title: "Location for automatic backups", type: "text", placeholder: "/mnt/data/backup/xteve/", + description: "Before any update of the provider data by the schedule, xTeVe creates a backup. The path for the automatic backups can be changed. xTeVe requires write permission for this folder." }, + { key: "temp.path", title: "Location for the temporary files", type: "text", placeholder: "/tmp/xteve/", + description: "Location for the buffer files." }, + { key: "user.agent", title: "User Agent", type: "text", placeholder: "xTeVe", + description: "User Agent for HTTP requests. For every HTTP connection, this value is used for the user agent. Should only be changed if xTeVe is blocked." }, + { key: "plex.url", title: "Plex Server URL", type: "text", placeholder: "http://plex-host:32400", + description: "Base URL of your Plex server. Example: http://192.168.1.10:32400" }, + // The server sends the mask "********" and treats it coming back as "unchanged". + { key: "plex.token", title: "Plex API Token", type: "password", placeholder: "Plex X-Plex-Token", autocomplete: "off", + description: "Plex token used for authenticated API calls to refresh your DVR guide." }, + { key: "buffer.timeout", title: "Timeout for new client connections", type: "text", placeholder: "100", + description: "The xTeVe buffer waits until new client connections are established. Helpful for fast channel switching. Value in milliseconds." }, + { key: "ffmpeg.path", title: "FFmpeg Binary Path", type: "text", placeholder: "/path/to/ffmpeg", + description: "Path to FFmpeg binary." }, + { key: "ffmpeg.options", title: "FFmpeg Options", type: "text", placeholder: "Leave blank to set default settings", + description: "FFmpeg options.
Only change if you know what you are doing.
Leave blank to set default settings." }, + { key: "vlc.path", title: "VLC / CVLC Binary Path", type: "text", placeholder: "/path/to/cvlc", + description: "Path to VLC / CVLC binary." }, + { key: "vlc.options", title: "VLC / CVLC Options", type: "text", placeholder: "Leave blank to set default settings", + description: "VLC / CVLC options.
Only change if you know what you are doing.
Leave blank to set default settings." }, + { key: "udpxy", title: "UDPxy address", type: "text", placeholder: "host:port", + description: "The address of your UDPxy server. If set, and the channel URLs in the m3u is multicast, xTeVe will rewrite it so that it is accessed via the UDPxy service." }, + // Checkboxes + { key: "authentication.web", title: "WEB Authentication", type: "checkbox", + description: "Access to the web interface only possible with credentials." }, + { key: "authentication.pms", title: "PMS Authentication", type: "checkbox", + description: "Plex requests are only possible with authentication.
Warning!!! After activating this function xTeVe must be delete in the PMS DVR settings and set up again." }, + { key: "authentication.m3u", title: "M3U Authentication", type: "checkbox", + description: "Downloading the xteve.m3u file via an HTTP request is only possible with authentication." }, + { key: "authentication.xml", title: "XML Authentication", type: "checkbox", + description: "Downloading the xteve.xml file via an HTTP request is only possible with authentication" }, + // Only rendered while web authentication is on (see SettingsCategoryItem.createCategory). + { key: "authentication.api", title: "API Authentication", type: "checkbox", + description: "Access to the API interface is only possible with authentication." }, + { key: "files.update", title: "Updates all files at startup", type: "checkbox", + description: "Updates all playlists, tuner and XMLTV files at startup." }, + { key: "cache.images", title: "Image Caching", type: "checkbox", + description: "All images from the XMLTV file are cached, allowing faster rendering of the grid in the client.
Downloading the images may take a while and will be done in the background." }, + { key: "xepg.replace.missing.images", title: "Replace missing program images", type: "checkbox", + description: "If the poster in the XMLTV program is missing, the channel logo will be used." }, + { key: "api", title: "API Interface", type: "checkbox", + description: "Via API interface it is possible to send commands to xTeVe. API documentation is here" }, + { key: "use_plexAPI", title: "Use Plex API Refresh", type: "checkbox", + description: "When enabled, xTeVe calls Plex directly to refresh DVR guide data after lineup or XEPG updates." }, + // Selects + { key: "xepg.missing.epg.mode", title: "Missing EPG Handling", type: "select", + options: settingsOptions(["Strict (deactivate channels)", "Relaxed (keep active / dummy guide)"], ["strict", "relaxed"]), + description: "Strict: channels are deactivated when XMLTV mappings disappear.
Relaxed: channels stay active with last-known mappings and fall back to a dummy guide whenever guide data cannot be resolved." }, + { key: "tuner", title: "Number of Tuners", type: "select", + options: settingsNumberOptions(1, 100), + description: "Number of parallel connections that can be established to the provider.
Available for: Plex, Emby (HDHR), M3U (with active buffer).
After a change, xTeVe must be delete in the Plex / Emby DVR settings and set up again." }, + { key: "epgSource", title: "EPG Source", type: "select", + options: settingsOptions(["PMS", "XEPG"], ["PMS", "XEPG"]), + description: "PMS:
- Use EPG data from Plex or Emby

XEPG:
- Use of one or more XMLTV files
- Channel management
- M3U / XMLTV export (HTTP link for IPTV apps)" }, + { key: "backup.keep", title: "Number of backups to keep", type: "select", + options: settingsOptions(["5", "10", "20", "30", "40", "50"], ["5", "10", "20", "30", "40", "50"]), + description: "Number of backups to keep. Older backups are automatically deleted." }, + { key: "buffer.size.kb", title: "Buffer Size", type: "select", + options: settingsOptions(["0.5 MB", "1 MB", "2 MB", "3 MB", "4 MB", "5 MB", "6 MB", "7 MB", "8 MB"], ["512", "1024", "2048", "3072", "4096", "5120", "6144", "7168", "8192"]), + description: "Buffer size in MB.
M3U8: If the TS segment smaller then the buffer size, the file size of the segment is used." }, + { key: "buffer", title: "Stream Buffer", type: "select", + options: settingsOptions(["No Buffer (Client connects to the streaming server)", "xTeVe: (xTeVe connects to the streaming server)", "FFmpeg: (FFmpeg connects to the streaming server)", "VLC: (VLC connects to the streaming server)"], ["-", "xteve", "ffmpeg", "vlc"]), + description: "Functions of the buffer:
- The stream is passed from xTeVe, FFmpeg or VLC to Plex, Emby or M3U Player
- Small jerking of the streams can be compensated
- HLS / M3U8 support
- RTP / RTPS support (only FFmpeg or VLC)
- Re-streaming
- Separate tuner limit for each playlist" }, +]; +function getSettingsField(settingsKey) { + for (var i = 0; i < SETTINGS_FIELDS.length; i++) { + if (SETTINGS_FIELDS[i].key == settingsKey) { + return SETTINGS_FIELDS[i]; + } + } + return undefined; +} class SettingsCategory { constructor() { this.DocumentID = "content_settings"; @@ -2212,429 +2376,59 @@ class SettingsCategory { var element = document.createElement("HR"); return element; } - createSettings(settingsKey) { - var setting = document.createElement("TR"); + // createControl builds the input for one field, filled with the current value. + createControl(field, data) { var content = new PopupContent(); - var data = SERVER["settings"][settingsKey]; - switch (settingsKey) { - // Texteingaben - case "update": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Schedule for updating (Playlist, XMLTV, Backup)" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "update", data.toString()); - input.setAttribute("placeholder", "0000,1000,2000"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); + var control; + switch (field.type) { + case "checkbox": + control = content.createCheckbox(field.key); + control.checked = data; break; - case "backup.path": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Location for automatic backups" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "backup.path", data); - input.setAttribute("placeholder", "/mnt/data/backup/xteve/"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); + case "select": + var text = field.options.map(function (option) { return option.text; }); + var values = field.options.map(function (option) { return option.value; }); + control = content.createSelect(text, values, data, field.key); break; - case "temp.path": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Location for the temporary files" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "temp.path", data); - input.setAttribute("placeholder", "/tmp/xteve/"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "user.agent": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "User Agent" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "user.agent", data); - input.setAttribute("placeholder", "xTeVe"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "plex.url": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Plex Server URL" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "plex.url", data); - input.setAttribute("placeholder", "http://plex-host:32400"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "plex.token": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Plex API Token" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("password", "plex.token", data); - input.setAttribute("placeholder", "Plex X-Plex-Token"); - input.setAttribute("autocomplete", "off"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "buffer.timeout": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Timeout for new client connections" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "buffer.timeout", data); - input.setAttribute("placeholder", "100"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "ffmpeg.path": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "FFmpeg Binary Path" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "ffmpeg.path", data); - input.setAttribute("placeholder", "/path/to/ffmpeg"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "ffmpeg.options": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "FFmpeg Options" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "ffmpeg.options", data); - input.setAttribute("placeholder", "Leave blank to set default settings"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "vlc.path": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "VLC / CVLC Binary Path" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "vlc.path", data); - input.setAttribute("placeholder", "/path/to/cvlc"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "vlc.options": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "VLC / CVLC Options" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "vlc.options", data); - input.setAttribute("placeholder", "Leave blank to set default settings"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - // Checkboxen - case "authentication.web": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "WEB Authentication" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "authentication.pms": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "PMS Authentication" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "authentication.m3u": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "M3U Authentication" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "authentication.xml": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "XML Authentication" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "authentication.api": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "API Authentication" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "files.update": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Updates all files at startup" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "cache.images": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Image Caching" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "xepg.replace.missing.images": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Replace missing program images" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "api": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "API Interface" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "use_plexAPI": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Use Plex API Refresh" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createCheckbox(settingsKey); - input.checked = data; - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - // Select - case "xepg.missing.epg.mode": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Missing EPG Handling" + ":"; - var tdRight = document.createElement("TD"); - var text = ["Strict (deactivate channels)", "Relaxed (keep active / dummy guide)"]; - var values = ["strict", "relaxed"]; - var select = content.createSelect(text, values, data, settingsKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(select); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "tuner": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Number of Tuners" + ":"; - var tdRight = document.createElement("TD"); - var text = new Array(); - var values = new Array(); - for (var i = 1; i <= 100; i++) { - text.push(i); - values.push(i); + default: + // "text", "password", "number": lists (the update schedule) render comma separated. + var value = data; + if (Array.isArray(data)) { + value = data.join(","); + } + control = content.createInput(field.type, field.key, value); + if (field.placeholder != undefined) { + control.setAttribute("placeholder", field.placeholder); + } + if (field.autocomplete != undefined) { + control.setAttribute("autocomplete", field.autocomplete); } - var select = content.createSelect(text, values, data, settingsKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(select); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "epgSource": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "EPG Source" + ":"; - var tdRight = document.createElement("TD"); - var text = ["PMS", "XEPG"]; - var values = ["PMS", "XEPG"]; - var select = content.createSelect(text, values, data, settingsKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(select); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "backup.keep": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Number of backups to keep" + ":"; - var tdRight = document.createElement("TD"); - var text = ["5", "10", "20", "30", "40", "50"]; - var values = ["5", "10", "20", "30", "40", "50"]; - var select = content.createSelect(text, values, data, settingsKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(select); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "buffer.size.kb": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Buffer Size" + ":"; - var tdRight = document.createElement("TD"); - var text = ["0.5 MB", "1 MB", "2 MB", "3 MB", "4 MB", "5 MB", "6 MB", "7 MB", "8 MB"]; - var values = ["512", "1024", "2048", "3072", "4096", "5120", "6144", "7168", "8192"]; - var select = content.createSelect(text, values, data, settingsKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(select); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "buffer": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "Stream Buffer" + ":"; - var tdRight = document.createElement("TD"); - var text = ["No Buffer (Client connects to the streaming server)", "xTeVe: (xTeVe connects to the streaming server)", "FFmpeg: (FFmpeg connects to the streaming server)", "VLC: (VLC connects to the streaming server)"]; - var values = ["-", "xteve", "ffmpeg", "vlc"]; - var select = content.createSelect(text, values, data, settingsKey); - select.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(select); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); - break; - case "udpxy": - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "UDPxy address" + ":"; - var tdRight = document.createElement("TD"); - var input = content.createInput("text", "udpxy", data); - input.setAttribute("placeholder", "host:port"); - input.setAttribute("onchange", "javascript: this.className = 'changed'"); - tdRight.appendChild(input); - setting.appendChild(tdLeft); - setting.appendChild(tdRight); break; } + markChangedOnChange(control); + return control; + } + createSettings(settingsKey) { + var setting = document.createElement("TR"); + var field = getSettingsField(settingsKey); + if (field == undefined) { + return setting; + } + var data = SERVER["settings"][settingsKey]; + var tdLeft = document.createElement("TD"); + tdLeft.textContent = field.title + ":"; + var tdRight = document.createElement("TD"); + tdRight.appendChild(this.createControl(field, data)); + setting.appendChild(tdLeft); + setting.appendChild(tdRight); return setting; } createDescription(settingsKey) { var description = document.createElement("TR"); - var text; - switch (settingsKey) { - case "authentication.web": - text = "Access to the web interface only possible with credentials."; - break; - case "authentication.m3u": - text = "Downloading the xteve.m3u file via an HTTP request is only possible with authentication."; - break; - case "authentication.pms": - text = "Plex requests are only possible with authentication.
Warning!!! After activating this function xTeVe must be delete in the PMS DVR settings and set up again."; - break; - case "authentication.xml": - text = "Downloading the xteve.xml file via an HTTP request is only possible with authentication"; - break; - case "authentication.api": - if (SERVER["settings"]["authentication.web"] == true) { - text = "Access to the API interface is only possible with authentication."; - } - break; - case "backup.keep": - text = "Number of backups to keep. Older backups are automatically deleted."; - break; - case "backup.path": - text = "Before any update of the provider data by the schedule, xTeVe creates a backup. The path for the automatic backups can be changed. xTeVe requires write permission for this folder."; - break; - case "temp.path": - text = "Location for the buffer files."; - break; - case "buffer": - text = "Functions of the buffer:
- The stream is passed from xTeVe, FFmpeg or VLC to Plex, Emby or M3U Player
- Small jerking of the streams can be compensated
- HLS / M3U8 support
- RTP / RTPS support (only FFmpeg or VLC)
- Re-streaming
- Separate tuner limit for each playlist"; - break; - case "buffer.size.kb": - text = "Buffer size in MB.
M3U8: If the TS segment smaller then the buffer size, the file size of the segment is used."; - break; - case "buffer.timeout": - text = "The xTeVe buffer waits until new client connections are established. Helpful for fast channel switching. Value in milliseconds."; - break; - case "user.agent": - text = "User Agent for HTTP requests. For every HTTP connection, this value is used for the user agent. Should only be changed if xTeVe is blocked."; - break; - case "plex.url": - text = "Base URL of your Plex server. Example: http://192.168.1.10:32400"; - break; - case "plex.token": - text = "Plex token used for authenticated API calls to refresh your DVR guide."; - break; - case "ffmpeg.path": - text = "Path to FFmpeg binary."; - break; - case "ffmpeg.options": - text = "FFmpeg options.
Only change if you know what you are doing.
Leave blank to set default settings."; - break; - case "vlc.path": - text = "Path to VLC / CVLC binary."; - break; - case "vlc.options": - text = "VLC / CVLC options.
Only change if you know what you are doing.
Leave blank to set default settings."; - break; - case "epgSource": - text = "PMS:
- Use EPG data from Plex or Emby

XEPG:
- Use of one or more XMLTV files
- Channel management
- M3U / XMLTV export (HTTP link for IPTV apps)"; - break; - case "tuner": - text = "Number of parallel connections that can be established to the provider.
Available for: Plex, Emby (HDHR), M3U (with active buffer).
After a change, xTeVe must be delete in the Plex / Emby DVR settings and set up again."; - break; - case "update": - text = "Time in 24 hour format (0800 = 8:00 am). More times can be entered comma separated. Leave this field empty if no updates are to be carried out."; - break; - case "api": - text = "Via API interface it is possible to send commands to xTeVe. API documentation is here"; - break; - case "use_plexAPI": - text = "When enabled, xTeVe calls Plex directly to refresh DVR guide data after lineup or XEPG updates."; - break; - case "files.update": - text = "Updates all playlists, tuner and XMLTV files at startup."; - break; - case "cache.images": - text = "All images from the XMLTV file are cached, allowing faster rendering of the grid in the client.
Downloading the images may take a while and will be done in the background."; - break; - case "xepg.replace.missing.images": - text = "If the poster in the XMLTV program is missing, the channel logo will be used."; - break; - case "xepg.missing.epg.mode": - text = "Strict: channels are deactivated when XMLTV mappings disappear.
Relaxed: channels stay active with last-known mappings and fall back to a dummy guide whenever guide data cannot be resolved."; - break; - case "udpxy": - text = "The address of your UDPxy server. If set, and the channel URLs in the m3u is multicast, xTeVe will rewrite it so that it is accessed via the UDPxy service."; - break; - default: - text = ""; - break; + var field = getSettingsField(settingsKey); + var text = ""; + if (field != undefined) { + text = field.description; } var tdLeft = document.createElement("TD"); tdLeft.innerHTML = ""; @@ -2706,6 +2500,7 @@ function saveSettings() { newSettings[name] = value; break; case "text": + case "password": name = settings[i].name; value = settings[i].value; switch (name) { @@ -2837,6 +2632,31 @@ function showElement(elmID, type) { } element.className = cssClass; } +// announce writes a message into the ARIA live region so assistive +// technology hears it; the region is created when the page has none. +function announce(message) { + if (document.body == null) { + return; + } + var region = document.getElementById("announcer"); + if (region == null) { + region = document.getElementById("sr-announcer"); + } + if (region == null) { + region = document.createElement("DIV"); + region.id = "announcer"; + region.className = "sr-only"; + region.setAttribute("aria-live", "polite"); + region.setAttribute("aria-atomic", "true"); + document.body.appendChild(region); + } + region.textContent = message; +} +// alertUser shows the browser alert and mirrors it into the live region. +function alertUser(message) { + announce(message); + alert(message); +} function setConnectionState(state, text = "") { var label = text; if (label == undefined || label.length == 0) { @@ -2862,9 +2682,17 @@ function setConnectionState(state, text = "") { indicator.className = "status-" + state; indicator.innerText = label; } -function changeButtonAction(element, buttonID, attribute) { - var value = element.options[element.selectedIndex].value; - document.getElementById(buttonID).setAttribute(attribute, value); +// markChangedOnChange flags a control as edited on "change" so that +// saveSettings / donePopupData pick it up, then runs an optional follow-up. +// Callers build many controls through one reused `var`, so the listener must +// close over this parameter and not over the caller's variable. +function markChangedOnChange(element, then) { + element.addEventListener("change", function () { + element.className = "changed"; + if (then != undefined) { + then(element); + } + }); } function getLocalData(dataType, id) { var data = new Object(); @@ -3151,7 +2979,7 @@ function changeChannelNumber(element) { var data = SERVER["xepg"]["epgMapping"]; var channels = getObjKeys(data); if (isNaN(newNumber)) { - alert("Invalid channel number"); + alertUser("Invalid channel number"); return; } channels.forEach(id => { @@ -3208,7 +3036,7 @@ function toggleChannelStatus(id) { case true: if (channel["x-xmltv-file"] == "-" || channel["x-mapping"] == "-") { if (BULK_EDIT == false) { - alert(channel["x-name"] + ": Missing XMLTV file / channel"); + alertUser(channel["x-name"] + ": Missing XMLTV file / channel"); checkbox.checked = false; } channel["x-active"] = false; @@ -3255,7 +3083,7 @@ function restore() { }; } else { - alert("File could not be loaded"); + alertUser("File could not be loaded"); } restore.remove(); return; @@ -3297,7 +3125,7 @@ function uploadLogo() { }; } else { - alert("File could not be loaded"); + alertUser("File could not be loaded"); } upload.remove(); return; @@ -3445,7 +3273,7 @@ function saveWizard() { value = config[i].value; if (value.length == 0) { var msg = name.toUpperCase() + ": " + "Missing input"; - alert(msg); + alertUser(msg); return; } wizard[name] = value; diff --git a/package-lock.json b/package-lock.json index 5c5bd4f..ee8ce86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,9 +6,466 @@ "": { "name": "xteve-webui", "devDependencies": { + "jsdom": "30.1.1", "typescript": "5.9.3" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz", + "integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.0", + "@csstools/css-color-parser": "^4.2.3", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz", + "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz", + "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz", + "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz", + "integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tldts": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.15.tgz", + "integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.15" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.15.tgz", + "integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -22,6 +479,81 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 7d97cfd..20af33b 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,11 @@ "description": "Build tooling for the xTeVe web interface (TypeScript -> html/js/app.js)", "scripts": { "build": "tsc -p ts/tsconfig.json", - "check": "tsc -p ts/tsconfig.json --noEmit" + "check": "tsc -p ts/tsconfig.json --noEmit", + "test": "node --test \"tests/**/*.test.js\"" }, "devDependencies": { + "jsdom": "30.1.1", "typescript": "5.9.3" } } diff --git a/tasks/todo.md b/tasks/todo.md index c4cfd17..173f2a3 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,7 +1,7 @@ # xTeVe improvement checklist Detailed rationale, file references and effort estimates: `tasks/improvement-plan.md`. -Status: Phases 0, 1, 2, 3 done on branch `improvements` (2026-09-26); Phase 5 in progress. +Status: all phases done on branch `improvements` (2026-09-26). Open: the `agent.md` design-system reference (user decision) and two items kept by design (Host-derived URLs, no user roles). ## Phase 0: Hygiene - [x] `.gitignore` (`.gocache/`), deleted `.gocache/`, extended `.dockerignore` @@ -59,18 +59,18 @@ Not planned (lineup is ~170 channels, decided 2026-09-25). See plan for the refe ## Phase 5: Frontend architecture - [x] Persistent websocket: one socket per page, queued requests with ids echoed by the server, 12 s per-request timeout, exponential-backoff reconnect, log-poll dedupe. Server keeps serving on one connection and closes it on exit (upstream leaked one open socket per request). Test: `TestWSServesMultipleCommandsPerConnection` -- [ ] Section-level re-render instead of full `createLayout()` -- [ ] Virtualised mapping table -- [ ] Split `menu_ts.ts`; `addEventListener`; data-driven settings rows -- [ ] Accessibility follow-ups; light theme via `prefers-color-scheme` -- [ ] Consolidate CSS layers +- [x] `createLayout()` rebuilds the menu list only when the set of visible items changes; client info and status cards refresh in place (jsdom test keeps the same nodes and focus) +- [~] Virtualised mapping table skipped: ~170 channels render instantly, and sorting/search operate on DOM rows, so virtualisation would add risk for no gain (same reasoning as dropping Phase 4) +- [x] `menu_ts.ts` split into `menu.ts`, `content.ts`, `popup.ts`, `xmltv.ts` (all `_ts` suffixes dropped); all 94 string `on*` handlers replaced by `addEventListener`; settings rows generated from `SETTINGS_FIELDS`; password fields now saved (Plex token); 27 jsdom tests under `tests/` run by `npm test` in CI +- [x] Live-region announcements alongside alerts, `lang`, single h1, `
` landmark; light theme via `prefers-color-scheme` with `data-theme` override (contrast measured). `` kept because every button style keys on it +- [x] CSS split into tokens / base / components / layout, every colour a token, `screen.css` removed ## Phase 6: Optional features -- [ ] Regex filters -- [ ] Per-playlist tuner limit and buffer choice -- [ ] Bulk channel edit -- [ ] Versioned backup/restore -- [ ] Dummy EPG durations +- [x] Regex filters: `regex-filter` type (validated on save, cached compile, matches name, then group, then raw line) with a popup form and table label; tested in Go and jsdom +- [x] Per-playlist tuner limit already existed. Per-playlist buffer: `buffer` select in the playlist popup (default / none / xTeVe / FFmpeg / VLC) backed by `bufferModeFor`; tuner field stays editable when the playlist's own buffer is active +- [x] Already present in the fork (bulk edit with shift-range selection); nothing to add +- [x] Already present: restore checks the backup's version against `System.Compatibility`; writes are now atomic (Phase 2) +- [x] Already present: `xTeVe Dummy` offers several durations in the mapping dropdown ## Review diff --git a/tests/app.test.js b/tests/app.test.js new file mode 100644 index 0000000..a425ee1 --- /dev/null +++ b/tests/app.test.js @@ -0,0 +1,218 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const vm = require("vm"); + +const {loadApp, readBundle} = require("./harness"); +const {makeServer, M3U_ID} = require("./fixture"); + +function menuKeys(document) { + return Array.from(document.querySelectorAll("#main-menu li")).map(li => li.getAttribute("data-menu")); +} + +function clickMenu(document, key) { + const item = document.querySelector('#main-menu li[data-menu="' + key + '"]'); + assert.ok(item, "menu item " + key + " is rendered"); + item.click(); + return item; +} + +test("bundle evaluates without throwing", () => { + const app = loadApp(); + assert.equal(typeof app.window.createLayout, "function"); + assert.equal(typeof app.window.PageReady, "function"); + assert.equal(app.evalIn("typeof Server"), "function"); + assert.equal(app.evalIn("typeof WSClient"), "function"); + assert.equal(app.evalIn("menuItems.length"), 8); +}); + +test("bundle evaluates against a bare window without a document", () => { + const context = vm.createContext({ + window: {}, + console, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + WebSocket: function() {}, + }); + context.window = context; + assert.doesNotThrow(() => vm.runInContext(readBundle(), context, {filename: "app.js"})); +}); + +test("createLayout hides users and logout while web authentication is off", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + + const keys = menuKeys(app.document); + assert.deepEqual(keys, ["playlist", "filter", "xmltv", "mapping", "settings", "log"]); + assert.equal(app.document.getElementById("version").textContent, "2.5.0"); + assert.equal(app.document.querySelectorAll("#status-cards .status-card").length, 6); +}); + +test("createLayout shows users and logout once web authentication is on", () => { + const app = loadApp({server: makeServer({settings: {"authentication.web": true}})}); + app.window.createLayout(); + + const keys = menuKeys(app.document); + assert.ok(keys.includes("users"), "users item present"); + assert.ok(keys.includes("logout"), "logout item present"); + assert.equal(keys.length, 8); +}); + +test("createLayout hides mapping and xmltv unless the EPG source is XEPG", () => { + const app = loadApp({server: makeServer({clientInfo: {epgSource: "PMS"}, settings: {epgSource: "PMS"}})}); + app.window.createLayout(); + + const keys = menuKeys(app.document); + assert.ok(!keys.includes("mapping"), "mapping hidden"); + assert.ok(!keys.includes("xmltv"), "xmltv hidden"); + assert.ok(keys.includes("playlist"), "playlist still present"); +}); + +test("createLayout opens the first menu item when nothing is active", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + + const active = app.document.querySelector("#main-menu li.menu-active"); + assert.ok(active, "an item is marked active"); + assert.equal(active.getAttribute("data-menu"), "playlist"); + assert.equal(app.document.body.getAttribute("data-active-menu"), "playlist"); + assert.equal(app.document.querySelectorAll("#content_table tr").length, 2, "header plus one playlist row"); +}); + +test("mapping menu renders one row per channel with channel names as text", () => { + const server = makeServer(); + const app = loadApp({server}); + app.window.createLayout(); + clickMenu(app.document, "mapping"); + + const table = app.document.getElementById("content_table"); + assert.ok(table, "mapping table rendered"); + + const channelCount = Object.keys(server.xepg.epgMapping).length; + const rows = Array.from(table.querySelectorAll("tr")).slice(1); + assert.equal(rows.length, channelCount); + + const ids = rows.map(tr => tr.id).sort(); + assert.deepEqual(ids, Object.keys(server.xepg.epgMapping).sort()); + + const hostile = app.document.getElementById(M3U_ID + "-2"); + assert.ok(hostile, "row for the hostile channel name exists"); + assert.equal(hostile.querySelector("b"), null, "channel name is not parsed as markup"); + const names = rows.map(tr => tr.children[3].textContent); + assert.ok(names.includes("x"), "channel name appears literally"); + assert.ok(names.includes("News 24")); + + const playlists = rows.map(tr => tr.children[4].textContent); + assert.ok(playlists.every(p => p === "Provider A"), "playlist name resolved from the file id"); + + const guides = rows.map(tr => tr.children[6].textContent).sort(); + assert.deepEqual(guides, ["-", "Guide A", "Guide A", "Guide A", "xTeVe Dummy"]); + + const inactive = app.document.getElementById(M3U_ID + "-5"); + assert.equal(inactive.className, "notActiveEPG"); + assert.equal(app.document.getElementById(M3U_ID + "-1").className, "activeEPG"); + + const edit = hostile.querySelector(".mapping-edit-button"); + assert.ok(edit, "edit button rendered"); + assert.equal(edit.getAttribute("aria-label"), "Edit x"); +}); + +test("mapping edit button opens the popup for its channel", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "mapping"); + + const edit = app.document.getElementById(M3U_ID + "-3").querySelector(".mapping-edit-button"); + edit.click(); + + const popup = app.document.getElementById("popup-custom"); + assert.equal(popup.querySelector("h3").textContent, "MAPPING"); + assert.equal(popup.querySelector('input[name="x-name"]').value, "Sports HD"); + assert.equal(popup.querySelector('select[name="x-xmltv-file"]').value, "X-1-def.xml"); + assert.equal(popup.querySelector('select[name="x-mapping"]').value, "ch3.example"); +}); + +function expectedSettingsKeys(app, webAuth) { + const all = app.evalIn("settingsCategory.map(function(c) { return c.settingsKeys }).join(',')").split(","); + if (webAuth) { + return all; + } + return all.filter(key => !["authentication.pms", "authentication.m3u", "authentication.xml", "authentication.api"].includes(key)); +} + +function assertSettingsControls(app, keys) { + const panel = app.document.getElementById("content_settings"); + assert.ok(panel, "settings panel rendered"); + + const controls = Array.from(panel.querySelectorAll("input[name], select[name]")); + assert.equal(controls.length, keys.length, "one control per settings key"); + + for (const key of keys) { + const matches = controls.filter(c => c.getAttribute("name") === key); + assert.equal(matches.length, 1, "exactly one control for " + key); + } + return controls; +} + +test("settings panel renders one input per settings key", () => { + const server = makeServer(); + const app = loadApp({server}); + app.window.createLayout(); + clickMenu(app.document, "settings"); + + const keys = expectedSettingsKeys(app, false); + const controls = assertSettingsControls(app, keys); + const byName = Object.fromEntries(controls.map(c => [c.getAttribute("name"), c])); + + assert.equal(byName["tuner"].tagName, "SELECT"); + assert.equal(byName["tuner"].value, "1"); + assert.equal(byName["buffer"].value, "ffmpeg"); + assert.equal(byName["epgSource"].value, "XEPG"); + assert.equal(byName["plex.token"].type, "password"); + assert.equal(byName["plex.token"].value, "********"); + assert.equal(byName["update"].value, "0000,1200"); + assert.equal(byName["files.update"].type, "checkbox"); + assert.equal(byName["files.update"].checked, true); + assert.equal(byName["cache.images"].checked, false); + assert.equal(byName["authentication.web"].checked, false); +}); + +test("settings panel includes the per-service authentication keys when web auth is on", () => { + const app = loadApp({server: makeServer({settings: {"authentication.web": true, "authentication.api": true}})}); + app.window.createLayout(); + clickMenu(app.document, "settings"); + + const keys = expectedSettingsKeys(app, true); + const controls = assertSettingsControls(app, keys); + const api = controls.find(c => c.getAttribute("name") === "authentication.api"); + assert.equal(api.checked, true); +}); + +test("saveSettings sends only the changed controls", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "settings"); + + const tuner = app.document.querySelector('#content_settings select[name="tuner"]'); + tuner.value = "4"; + tuner.dispatchEvent(new app.window.Event("change", {bubbles: true})); + + const agent = app.document.querySelector('#content_settings input[name="user.agent"]'); + agent.value = "Custom"; + agent.dispatchEvent(new app.window.Event("change", {bubbles: true})); + + app.window.saveSettings(); + + const socket = app.sockets[app.sockets.length - 1]; + assert.ok(socket, "a websocket was opened"); + socket.readyState = 1; + socket.onopen(); + + assert.equal(socket.sent.length, 1); + const payload = JSON.parse(socket.sent[0]); + assert.equal(payload.cmd, "saveSettings"); + assert.deepEqual(payload.settings, {"tuner": 4, "user.agent": "Custom"}); +}); diff --git a/tests/fixture.js b/tests/fixture.js new file mode 100644 index 0000000..c561b55 --- /dev/null +++ b/tests/fixture.js @@ -0,0 +1,162 @@ +"use strict"; + +// A SERVER payload shaped like the answer to getServerConfig. Every call +// returns a fresh deep copy so tests can mutate it freely. + +const M3U_ID = "M-1-abc"; +const XMLTV_ID = "X-1-def"; +const XMLTV_FILE = XMLTV_ID + ".xml"; + +function channel(number, name, extra) { + return Object.assign({ + "_file.m3u.id": M3U_ID, + "_file.m3u.name": "Provider A", + "_file.m3u.path": "/config/data/" + M3U_ID + ".m3u", + "_uuid.key": "CUID", + "_uuid.value": "cuid-" + number, + "group-title": "News", + "name": name, + "tvg-id": "", + "tvg-logo": "http://logos.example/" + number + ".png", + "tvg-name": name, + "url": "http://stream.example/" + number, + "x-active": true, + "x-category": "", + "x-channelID": String(number), + "x-description": "", + "x-group-title": "News", + "x-mapping": "ch" + number + ".example", + "x-name": name, + "x-update-channel-icon": true, + "x-update-channel-name": true, + "x-xmltv-file": XMLTV_FILE, + }, extra || {}); +} + +function makeServer(overrides) { + const server = { + clientInfo: { + arch: "darwin/arm64", + os: "darwin", + version: "2.5.0", + uuid: "xteve-test-uuid", + DVR: "192.168.1.10:34400", + "m3u-url": "http://192.168.1.10:34400/m3u/xteve.m3u", + "xepg-url": "http://192.168.1.10:34400/xmltv/xteve.xml", + streams: "5", + epgSource: "XEPG", + xepg: "4", + errors: "0", + warnings: "1", + }, + settings: { + "api": false, + "authentication.api": false, + "authentication.m3u": false, + "authentication.pms": false, + "authentication.web": false, + "authentication.xml": false, + "backup.keep": 10, + "backup.path": "/config/backup/", + "buffer": "ffmpeg", + "buffer.size.kb": 1024, + "buffer.timeout": 500, + "cache.images": false, + "epgSource": "XEPG", + "ffmpeg.options": "-hide_banner -loglevel error -i [URL] -c copy -f mpegts pipe:1", + "ffmpeg.path": "/usr/bin/ffmpeg", + "files.update": true, + "plex.token": "********", + "plex.url": "http://plex.example:32400", + "temp.path": "/tmp/xteve/", + "tuner": 1, + "udpxy": "", + "update": ["0000", "1200"], + "use_plexAPI": false, + "user.agent": "xTeVe", + "vlc.options": "--intf dummy --no-audio --no-video --sout '#standard{access=file,mux=ts,dst=-}' [URL]", + "vlc.path": "/usr/bin/cvlc", + "xepg.missing.epg.mode": "strict", + "xepg.replace.missing.images": true, + files: { + m3u: { + [M3U_ID]: { + "compatibility": {"group.title": 100, "stream.id": 100, "streams": 5, "tvg.id": 80}, + "counter.download": 3, + "counter.error": 0, + "description": "Main playlist", + "file.source": "http://provider.example/list.m3u", + "file.xteve": M3U_ID + ".m3u", + "last.update": "2026-09-01 10:00:00", + "name": "Provider A", + "provider.availability": 100, + "tuner": 2, + "type": "m3u", + }, + }, + hdhr: {}, + xmltv: { + [XMLTV_ID]: { + "compatibility": {"xmltv.channels": 40, "xmltv.programs": 12000}, + "counter.download": 3, + "counter.error": 0, + "description": "Main guide", + "file.source": "http://provider.example/guide.xml", + "file.xteve": XMLTV_FILE, + "last.update": "2026-09-01 10:05:00", + "name": "Guide A", + "provider.availability": 100, + "type": "xmltv", + }, + }, + }, + filter: {}, + }, + xepg: { + epgMapping: { + [M3U_ID + "-1"]: channel(1, "News 24"), + [M3U_ID + "-2"]: channel(2, "x"), + [M3U_ID + "-3"]: channel(3, "Sports HD", {"x-group-title": "Sport", "group-title": "Sport", "x-category": "Sports"}), + [M3U_ID + "-4"]: channel(4, "Kids TV", {"x-xmltv-file": "xTeVe Dummy", "x-mapping": "30_Minutes"}), + [M3U_ID + "-5"]: channel(5, "Unmapped", {"x-active": false, "x-xmltv-file": "-", "x-mapping": "-"}), + }, + xmltvMap: { + [XMLTV_FILE]: { + "ch1.example": {id: "ch1.example", "display-name": "News 24", icon: "http://logos.example/1.png"}, + "ch2.example": {id: "ch2.example", "display-name": "Channel Two", icon: ""}, + "ch3.example": {id: "ch3.example", "display-name": "Sports HD", icon: "http://logos.example/3.png"}, + }, + "xTeVe Dummy": { + "30_Minutes": {id: "30_Minutes", "display-name": "30 Minutes", icon: ""}, + "60_Minutes": {id: "60_Minutes", "display-name": "60 Minutes", icon: ""}, + }, + }, + }, + data: { + StreamPreviewUI: {activeStreams: ["News 24", "Sports HD"], inactiveStreams: ["Unmapped"]}, + playlist: {m3u: {groups: {text: ["News (2)", "Sport (1)"], value: ["News", "Sport"]}}}, + }, + users: {}, + log: {log: {}}, + status: true, + }; + + const copy = JSON.parse(JSON.stringify(server)); + if (overrides) { + deepMerge(copy, overrides); + } + return copy; +} + +function deepMerge(target, source) { + for (const key of Object.keys(source)) { + const value = source[key]; + if (value && typeof value === "object" && !Array.isArray(value) && target[key] && typeof target[key] === "object") { + deepMerge(target[key], value); + } else { + target[key] = value; + } + } +} + +module.exports = {makeServer, M3U_ID, XMLTV_ID, XMLTV_FILE}; diff --git a/tests/handlers.test.js b/tests/handlers.test.js new file mode 100644 index 0000000..68bf0a4 --- /dev/null +++ b/tests/handlers.test.js @@ -0,0 +1,226 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const {loadApp} = require("./harness"); +const {makeServer, M3U_ID, XMLTV_ID} = require("./fixture"); + +function clickMenu(document, key) { + const item = document.querySelector('#main-menu li[data-menu="' + key + '"]'); + assert.ok(item, "menu item " + key + " is rendered"); + item.click(); + return item; +} + +function inlineHandlerAttributes(root) { + const found = []; + for (const element of root.querySelectorAll("*")) { + for (const attr of element.attributes) { + if (/^on/i.test(attr.name)) { + found.push(element.tagName + "@" + attr.name); + } + } + } + return found; +} + +function change(window, element) { + element.dispatchEvent(new window.Event("change", {bubbles: true})); +} + +test("rendered pages carry no inline event handler attributes", () => { + const app = loadApp({server: makeServer({settings: {"authentication.web": true}})}); + app.window.createLayout(); + + for (const key of ["playlist", "filter", "xmltv", "mapping", "users", "settings", "log"]) { + clickMenu(app.document, key); + assert.deepEqual(inlineHandlerAttributes(app.document.body), [], "no inline handlers after opening " + key); + } + + clickMenu(app.document, "playlist"); + app.document.getElementById(M3U_ID).click(); + assert.deepEqual(inlineHandlerAttributes(app.document.getElementById("popup-custom")), [], "no inline handlers in the m3u popup"); +}); + +test("menu items open their content through listeners", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + + clickMenu(app.document, "xmltv"); + const rows = app.document.querySelectorAll("#content_table tr"); + assert.equal(rows.length, 2); + assert.equal(rows[1].id, XMLTV_ID); + assert.equal(app.document.body.getAttribute("data-active-menu"), "xmltv"); +}); + +test("playlist rows open the edit popup and the New button walks the type chooser", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "playlist"); + + app.document.getElementById(M3U_ID).click(); + const popup = app.document.getElementById("popup-custom"); + assert.equal(popup.querySelector("h3").textContent, "M3U"); + assert.equal(popup.querySelector('input[name="name"]').value, "Provider A"); + assert.equal(popup.querySelector('select[name="tuner"]').value, "2"); + assert.ok(popup.querySelector('input[name="delete"]'), "existing playlist offers delete"); + + app.document.querySelector('#content-interaction input[name="playlist"]').click(); + assert.equal(popup.querySelector("h3").textContent, "PLAYLIST TYPE"); + const type = popup.querySelector("select#type"); + assert.deepEqual(Array.from(type.options).map(o => o.value), ["m3u", "hdhr"]); + + type.value = "hdhr"; + change(app.window, type); + app.document.getElementById("next").click(); + assert.equal(popup.querySelector("h3").textContent, "HDHR"); + assert.equal(popup.querySelector('input[name="back"]') != null, true, "new entry offers back instead of delete"); + assert.equal(popup.querySelector('input[name="delete"]'), null); + + popup.querySelector('input[name="back"]').click(); + assert.equal(popup.querySelector("h3").textContent, "PLAYLIST TYPE"); + app.document.getElementById("next").click(); + assert.equal(popup.querySelector("h3").textContent, "M3U"); + + popup.querySelector('input[name="cancel"]').click(); + assert.equal(app.document.getElementById("popup").className, "none"); +}); + +test("filter New button offers group-title and custom filters", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "filter"); + + app.document.querySelector('#content-interaction input[name="filter"]').click(); + const popup = app.document.getElementById("popup-custom"); + assert.equal(popup.querySelector("h3").textContent, "FILTER"); + const type = popup.querySelector('select[name="type"]'); + assert.equal(type.value, "group-title"); + + type.value = "custom-filter"; + change(app.window, type); + app.document.getElementById("next").click(); + assert.equal(popup.querySelector("h3").textContent, "CUSTOM"); + assert.equal(popup.querySelector('input[name="type"]').value, "custom-filter"); + assert.ok(popup.querySelector('input[name="filter"]'), "custom filter rule input"); +}); + +test("mapping popup marks edited controls and applies them on Done", () => { + const server = makeServer(); + const app = loadApp({server}); + app.window.createLayout(); + clickMenu(app.document, "mapping"); + + const id = M3U_ID + "-1"; + app.document.getElementById(id).querySelector(".mapping-edit-button").click(); + const popup = app.document.getElementById("popup-custom"); + + const name = popup.querySelector('input[name="x-name"]'); + assert.equal(name.className, ""); + name.value = "Renamed"; + change(app.window, name); + assert.equal(name.className, "changed"); + + const group = popup.querySelector('input[name="x-group-title"]'); + group.value = "Docs"; + change(app.window, group); + + popup.querySelector('input[name="save"]').click(); + + const row = app.document.getElementById(id); + assert.equal(row.children[3].textContent, "Renamed"); + assert.equal(row.children[5].textContent, "Docs"); + assert.equal(server.xepg.epgMapping[id]["x-name"], "Renamed"); + assert.equal(server.xepg.epgMapping[id]["x-group-title"], "Docs"); + assert.equal(app.document.getElementById("popup").className, "none"); +}); + +test("changing the XMLTV file in the mapping popup rebuilds the channel list", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "mapping"); + + app.document.getElementById(M3U_ID + "-1").querySelector(".mapping-edit-button").click(); + const popup = app.document.getElementById("popup-custom"); + + const file = popup.querySelector("#popup-xmltv"); + file.value = "xTeVe Dummy"; + change(app.window, file); + + const mapping = popup.querySelector("#popup-mapping"); + const values = Array.from(mapping.options).map(o => o.value); + assert.ok(values.includes("30_Minutes"), "dummy programs listed"); + assert.equal(mapping.className, "changed"); + assert.equal(popup.querySelector("#update-icon").className, "changed"); + + mapping.value = "60_Minutes"; + change(app.window, mapping); + assert.equal(popup.querySelector("#active").checked, true); +}); + +test("mapping table header sorts by channel name on click", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "mapping"); + + const header = app.document.querySelector("#content_table tr"); + const nameHeader = header.children[3]; + assert.equal(nameHeader.className, "pointer"); + nameHeader.click(); + + const names = Array.from(app.document.querySelectorAll("#content_table tr")).slice(1).map(tr => tr.children[3].textContent); + assert.deepEqual(names, names.slice().sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)); +}); + +test("bulk header checkbox selects every visible channel", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "mapping"); + app.document.querySelector('#content-interaction input[value="Bulk Edit"]').click(); + + const head = app.document.querySelector("#content_table tr input.bulk"); + head.click(); + + const boxes = Array.from(app.document.querySelectorAll("#content_table tr")).slice(1).map(tr => tr.querySelector("input.bulk")); + assert.equal(boxes.length, 5); + assert.ok(boxes.every(b => b.checked), "every row checkbox is ticked"); + assert.ok(boxes.every(b => b.className == "bulk showBulk")); +}); + +test("filter New button offers a regular-expression filter", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + clickMenu(app.document, "filter"); + + app.document.querySelector('#content-interaction input[name="filter"]').click(); + const popup = app.document.getElementById("popup-custom"); + const type = popup.querySelector('select[name="type"]'); + const values = Array.from(type.options).map((o) => o.value); + assert.deepEqual(values, ["group-title", "custom-filter", "regex-filter"]); + + type.value = "regex-filter"; + change(app.window, type); + app.document.getElementById("next").click(); + assert.equal(popup.querySelector("h3").textContent, "REGULAR EXPRESSION"); + assert.equal(popup.querySelector('input[name="type"]').value, "regex-filter"); + assert.ok(popup.querySelector('input[name="filter"]'), "regex rule input"); + assert.ok(popup.querySelector('input[name="caseSensitive"]'), "case sensitive checkbox"); +}); + +test("playlist popup offers a per-playlist buffer choice and keeps the tuner editable", () => { + const server = makeServer(); + const app = loadApp({server}); + app.window.createLayout(); + clickMenu(app.document, "playlist"); + + const row = app.document.querySelector("#content_table tbody tr, #content_table tr[id]"); + assert.ok(row, "a playlist row"); + row.click(); + const popup = app.document.getElementById("popup-custom"); + const buffer = popup.querySelector('select[name="buffer"]'); + assert.ok(buffer, "buffer select"); + assert.deepEqual(Array.from(buffer.options).map((o) => o.value), ["default", "-", "xteve", "ffmpeg", "vlc"]); + assert.equal(buffer.value, "default"); + assert.ok(popup.querySelector('select[name="tuner"]'), "tuner select shown while a buffer is active"); +}); diff --git a/tests/harness.js b/tests/harness.js new file mode 100644 index 0000000..7dd2643 --- /dev/null +++ b/tests/harness.js @@ -0,0 +1,127 @@ +"use strict"; + +// Loads the compiled bundle (html/js/app.js) into a jsdom window so the +// global-script UI code can be driven from Node's test runner. + +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const {JSDOM, VirtualConsole} = require("jsdom"); + +const BUNDLE_PATH = path.join(__dirname, "..", "html", "js", "app.js"); + +// A trimmed copy of index.html: only the ids the bundle looks up. +const SHELL_HTML = ` + + xTeVe + +
+ +
+
+ +
+
+ +

Connecting...

+
+ + + + + +
+
+
+
+
+
+
+

+
+
+ +`; + +function readBundle() { + return fs.readFileSync(BUNDLE_PATH, "utf8"); +} + +class FakeWebSocket { + constructor(url) { + this.url = url; + this.readyState = FakeWebSocket.CONNECTING; + this.sent = []; + this.onopen = null; + this.onmessage = null; + this.onerror = null; + this.onclose = null; + FakeWebSocket.instances.push(this); + } + send(data) { + this.sent.push(data); + } + close() { + this.readyState = FakeWebSocket.CLOSED; + } +} +FakeWebSocket.CONNECTING = 0; +FakeWebSocket.OPEN = 1; +FakeWebSocket.CLOSING = 2; +FakeWebSocket.CLOSED = 3; +FakeWebSocket.instances = []; + +// loadApp evaluates the bundle in a fresh window. Options: +// server - object assigned to the global SERVER after the bundle runs +// html - alternative document markup +function loadApp(options) { + options = options || {}; + + // The UI logs freely; keep that out of the test output but keep jsdom's + // own errors (unimplemented APIs, uncaught handler exceptions) visible. + const jsdomErrors = []; + const virtualConsole = new VirtualConsole(); + virtualConsole.on("jsdomError", err => { jsdomErrors.push(err); }); + + const dom = new JSDOM(options.html || SHELL_HTML, { + url: "http://localhost:34400/", + runScripts: "dangerously", + pretendToBeVisual: true, + virtualConsole, + }); + const window = dom.window; + + // jsdom does not implement innerText; the UI uses it as a textContent alias. + Object.defineProperty(window.HTMLElement.prototype, "innerText", { + configurable: true, + get() { return this.textContent; }, + set(value) { this.textContent = value; }, + }); + + const alerts = []; + const confirms = []; + window.alert = function(message) { alerts.push(message); }; + window.confirm = function(message) { confirms.push(message); return true; }; + + FakeWebSocket.instances = []; + window.WebSocket = FakeWebSocket; + + const context = dom.getInternalVMContext(); + vm.runInContext(readBundle(), context, {filename: "app.js"}); + + // evalIn runs code inside the window, where top-level class declarations + // from the bundle are visible (they are lexical bindings, not window props). + function evalIn(code) { + return vm.runInContext(code, context); + } + + if (options.server) { + window.SERVER = options.server; + } + + return {dom, window, document: window.document, evalIn, alerts, confirms, jsdomErrors, sockets: FakeWebSocket.instances}; +} + +module.exports = {loadApp, readBundle, SHELL_HTML, BUNDLE_PATH}; diff --git a/tests/layout.test.js b/tests/layout.test.js new file mode 100644 index 0000000..d7f73ec --- /dev/null +++ b/tests/layout.test.js @@ -0,0 +1,116 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const {loadApp, SHELL_HTML} = require("./harness"); +const {makeServer} = require("./fixture"); + +function menuKeys(document) { + return Array.from(document.querySelectorAll("#main-menu li")).map(li => li.getAttribute("data-menu")); +} + +test("createLayout keeps the menu nodes when the visible items are unchanged", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + + const before = Array.from(app.document.querySelectorAll("#main-menu li")); + const focused = before[2]; + focused.setAttribute("tabindex", "0"); + focused.focus(); + assert.equal(app.document.activeElement, focused); + + app.window.SERVER = makeServer({clientInfo: {streams: "9", errors: "3"}}); + app.window.createLayout(); + + const after = Array.from(app.document.querySelectorAll("#main-menu li")); + assert.equal(after.length, before.length); + for (let i = 0; i < before.length; i++) { + assert.equal(after[i], before[i], "menu node " + i + " is the same object"); + } + assert.equal(app.document.activeElement, focused, "focus survives the refresh"); + + assert.equal(app.document.getElementById("streams").textContent, "9", "client info refreshed"); + const errorCard = Array.from(app.document.querySelectorAll("#status-cards .status-card")).find(c => c.textContent.startsWith("Errors")); + assert.ok(errorCard.className.includes("status-card-error"), "status cards refreshed"); +}); + +test("createLayout rebuilds the menu when the visible items change", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + assert.deepEqual(menuKeys(app.document), ["playlist", "filter", "xmltv", "mapping", "settings", "log"]); + + const playlist = app.document.querySelector('#main-menu li[data-menu="playlist"]'); + + app.window.SERVER = makeServer({settings: {"authentication.web": true}}); + app.window.createLayout(); + assert.deepEqual(menuKeys(app.document), ["playlist", "filter", "xmltv", "mapping", "users", "settings", "log", "logout"]); + assert.notEqual(app.document.querySelector('#main-menu li[data-menu="playlist"]'), playlist, "list was rebuilt"); + + const active = app.document.querySelector("#main-menu li.menu-active"); + assert.ok(active, "active item restored after rebuild"); + assert.equal(active.getAttribute("data-menu"), "playlist"); + + app.window.SERVER = makeServer({clientInfo: {epgSource: "PMS"}}); + app.window.createLayout(); + assert.deepEqual(menuKeys(app.document), ["playlist", "filter", "settings", "log"]); +}); + +test("createLayout keeps the active menu when another item is selected", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + app.document.querySelector('#main-menu li[data-menu="mapping"]').click(); + assert.equal(app.document.body.getAttribute("data-active-menu"), "mapping"); + + app.window.SERVER = makeServer(); + app.window.createLayout(); + assert.equal(app.document.body.getAttribute("data-active-menu"), "mapping"); + assert.equal(app.document.querySelector("#main-menu li.menu-active").getAttribute("data-menu"), "mapping"); + assert.equal(app.document.querySelectorAll("#content_table tr").length, 6, "mapping content left in place"); +}); + +test("alertUser mirrors the message into the existing live region", () => { + const app = loadApp({server: makeServer()}); + app.window.alertUser("Something went wrong"); + + assert.deepEqual(app.alerts, ["Something went wrong"]); + assert.equal(app.document.getElementById("sr-announcer").textContent, "Something went wrong"); + assert.equal(app.document.getElementById("announcer"), null, "no second region created"); +}); + +test("announce creates a polite live region when the page has none", () => { + const html = SHELL_HTML.replace(/

]*><\/p>/, ""); + const app = loadApp({server: makeServer(), html}); + assert.equal(app.document.getElementById("sr-announcer"), null); + + app.window.announce("Saved"); + + const region = app.document.getElementById("announcer"); + assert.ok(region, "region created"); + assert.equal(region.getAttribute("aria-live"), "polite"); + assert.equal(region.className, "sr-only"); + assert.equal(region.textContent, "Saved"); + + app.window.announce("Again"); + assert.equal(app.document.querySelectorAll("#announcer").length, 1); + assert.equal(region.textContent, "Again"); +}); + +test("server error responses alert and announce", () => { + const app = loadApp({server: makeServer()}); + app.window.createLayout(); + + app.evalIn('new Server("saveSettings").request({settings: {}})'); + const socket = app.sockets[app.sockets.length - 1]; + socket.readyState = 1; + socket.onopen(); + const sent = JSON.parse(socket.sent[0]); + + socket.onmessage({data: JSON.stringify({id: sent.id, status: false, err: "Setting rejected"})}); + + return new Promise(resolve => setImmediate(resolve)).then(() => { + assert.deepEqual(app.alerts, ["Setting rejected"]); + assert.equal(app.document.getElementById("sr-announcer").textContent, "Setting rejected"); + assert.equal(app.document.getElementById("connection-indicator").className, "status-offline"); + }); +}); diff --git a/ts/base.ts b/ts/base.ts index 293b9b1..58c1bec 100644 --- a/ts/base.ts +++ b/ts/base.ts @@ -60,6 +60,35 @@ function showElement(elmID, type) { element.className = cssClass; } +// announce writes a message into the ARIA live region so assistive +// technology hears it; the region is created when the page has none. +function announce(message:string) { + if (document.body == null) { + return + } + + var region = document.getElementById("announcer") + if (region == null) { + region = document.getElementById("sr-announcer") + } + if (region == null) { + region = document.createElement("DIV") + region.id = "announcer" + region.className = "sr-only" + region.setAttribute("aria-live", "polite") + region.setAttribute("aria-atomic", "true") + document.body.appendChild(region) + } + + region.textContent = message +} + +// alertUser shows the browser alert and mirrors it into the live region. +function alertUser(message:string) { + announce(message) + alert(message) +} + function setConnectionState(state:string, text:string = "") { var label:string = text @@ -92,9 +121,17 @@ function setConnectionState(state:string, text:string = "") { indicator.innerText = label } -function changeButtonAction(element, buttonID, attribute) { - var value = element.options[element.selectedIndex].value; - document.getElementById(buttonID).setAttribute(attribute, value) +// markChangedOnChange flags a control as edited on "change" so that +// saveSettings / donePopupData pick it up, then runs an optional follow-up. +// Callers build many controls through one reused `var`, so the listener must +// close over this parameter and not over the caller's variable. +function markChangedOnChange(element:HTMLElement, then?:(element:HTMLElement) => void) { + element.addEventListener("change", function() { + element.className = "changed" + if (then != undefined) { + then(element) + } + }) } function getLocalData(dataType, id):object { @@ -501,7 +538,7 @@ function changeChannelNumber(element) { var channels = getObjKeys(data) if (isNaN(newNumber)) { - alert("Invalid channel number") + alertUser("Invalid channel number") return } @@ -584,7 +621,7 @@ function toggleChannelStatus(id:string) { if (channel["x-xmltv-file"] == "-" || channel["x-mapping"] == "-") { if (BULK_EDIT == false) { - alert(channel["x-name"] + ": Missing XMLTV file / channel") + alertUser(channel["x-name"] + ": Missing XMLTV file / channel") checkbox.checked = false } @@ -649,7 +686,7 @@ function restore() { }; } else { - alert("File could not be loaded") + alertUser("File could not be loaded") } restore.remove() @@ -707,7 +744,7 @@ function uploadLogo() { }; } else { - alert("File could not be loaded") + alertUser("File could not be loaded") } upload.remove() diff --git a/ts/configuration.ts b/ts/configuration.ts index 687c295..2229def 100644 --- a/ts/configuration.ts +++ b/ts/configuration.ts @@ -144,7 +144,7 @@ function saveWizard() { if (value.length == 0) { var msg = name.toUpperCase() + ": " + "Missing input" - alert(msg) + alertUser(msg) return } diff --git a/ts/content.ts b/ts/content.ts new file mode 100644 index 0000000..b6347b0 --- /dev/null +++ b/ts/content.ts @@ -0,0 +1,764 @@ +class Content { + + DocumentID:string = "content" + TableID:string = "content_table" + DivID:string + headerClass:string = "content_table_header" + interactionID:string = "content-interaction" + + createHeadline(value):any { + var element = document.createElement("H3") + element.textContent = value + return element + } + + createHR():any { + var element = document.createElement("HR") + return element + } + + createInteraction():any { + var element = document.createElement("DIV") + element.setAttribute("id", this.interactionID) + return element + } + + createDIV():any { + var element = document.createElement("DIV") + element.id = this.DivID + return element + } + + createTABLE():any { + var element = document.createElement("TABLE") + element.id = this.TableID + return element + } + + createTableRow():any { + var element = document.createElement("TR") + element.className = this.headerClass + return element + } + + createTableContent(menuKey:string):string[] { + + var data = new Object() + var rows = new Array() + + switch(menuKey) { + case "playlist": + var fileTypes = new Array("m3u", "hdhr") + + fileTypes.forEach(fileType => { + + data = SERVER["settings"]["files"][fileType] + + var keys = getObjKeys(data) + + keys.forEach(key => { + var tr = document.createElement("TR") + tr.id = key + + tr.addEventListener("click", function() { openPopUp(fileType, tr) }) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["name"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + if (SERVER["settings"]["buffer"] != "-") { + cell.value = data[key]["tuner"] + } else { + cell.value = "-" + } + + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["last.update"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["provider.availability"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["type"].toUpperCase(); + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["compatibility"]["streams"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["compatibility"]["group.title"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["compatibility"]["tvg.id"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["compatibility"]["stream.id"] + tr.appendChild(cell.createCell()) + + rows.push(tr) + }); + + }); + break + + case "filter": + delete SERVER["settings"]["filter"][-1] + data = SERVER["settings"]["filter"] + var keys = getObjKeys(data) + keys.forEach(key => { + var tr = document.createElement("TR") + tr.id = key + + tr.addEventListener("click", function() { openPopUp(data[key]["type"], tr) }) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["name"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + switch (data[key]["type"]) { + case "custom-filter": + cell.value = "Custom" + break; + + case "group-title": + cell.value = "Group" + break; + + case "regex-filter": + cell.value = "Regex" + break; + + default: + break; + } + + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["filter"] + tr.appendChild(cell.createCell()) + + rows.push(tr) + + }); + break + + case "xmltv": + var fileTypes = new Array("xmltv") + + fileTypes.forEach(fileType => { + + data = SERVER["settings"]["files"][fileType] + + var keys = getObjKeys(data) + + keys.forEach(key => { + var tr = document.createElement("TR") + + tr.id = key + tr.addEventListener("click", function() { openPopUp(fileType, tr) }) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["name"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["last.update"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["provider.availability"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["compatibility"]["xmltv.channels"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["compatibility"]["xmltv.programs"] + tr.appendChild(cell.createCell()) + + rows.push(tr) + }); + + }); + break + + case "users": + var fileTypes = new Array("users") + + fileTypes.forEach(fileType => { + data = SERVER[fileType] + + var keys = getObjKeys(data) + + keys.forEach(key => { + var tr = document.createElement("TR") + tr.id = key + tr.addEventListener("click", function() { openPopUp(fileType, tr) }) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["data"]["username"] + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = "******" + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + if (data[key]["data"]["authentication.web"] == true) { + cell.value = "✓" + } else { + cell.value = "-" + } + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + if (data[key]["data"]["authentication.pms"] == true) { + cell.value = "✓" + } else { + cell.value = "-" + } + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + if (data[key]["data"]["authentication.m3u"] == true) { + cell.value = "✓" + } else { + cell.value = "-" + } + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + if (data[key]["data"]["authentication.xml"] == true) { + cell.value = "✓" + } else { + cell.value = "-" + } + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + if (data[key]["data"]["authentication.api"] == true) { + cell.value = "✓" + } else { + cell.value = "-" + } + tr.appendChild(cell.createCell()) + + rows.push(tr) + }); + + }); + break + + case "mapping": + BULK_EDIT = false + createSearchObj() + checkUndo("epgMapping") + console.log("MAPPING") + data = SERVER["xepg"]["epgMapping"] + + var keys = getObjKeys(data) + keys.forEach(key => { + var tr = document.createElement("TR") + tr.id = key + + + switch (data[key]["x-active"]) { + case true: + tr.className = "activeEPG" + break; + + case false: + tr.className = "notActiveEPG" + break; + } + + // Bulk + var cell:Cell = new Cell() + cell.child = true + cell.childType = "BULK" + cell.value = false + tr.appendChild(cell.createCell()) + + // Kanalnummer + var cell:Cell = new Cell() + cell.child = true + cell.childType = "INPUTCHANNEL" + cell.value = data[key]["x-channelID"] + tr.appendChild(cell.createCell()) + + // Logo + var cell:Cell = new Cell() + cell.child = true + cell.childType = "IMG" + cell.imageURL = data[key]["tvg-logo"] + tr.appendChild(cell.createCell()) + + // Kanalname + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.className = data[key]["x-category"] + cell.value = data[key]["x-name"] + tr.appendChild(cell.createCell()) + + + // Playlist + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + //cell.value = data[key]["_file.m3u.name"] + cell.value = getValueFromProviderFile(data[key]["_file.m3u.id"], "m3u", "name") + tr.appendChild(cell.createCell()) + + + // Gruppe (group-title) + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = data[key]["x-group-title"] + tr.appendChild(cell.createCell()) + + // XMLTV Datei + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + + if (data[key]["x-xmltv-file"] != "-") { + cell.value = getValueFromProviderFile(data[key]["x-xmltv-file"], "xmltv", "name") + } else { + cell.value = data[key]["x-xmltv-file"] + } + + tr.appendChild(cell.createCell()) + + // XMLTV Kanal + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + //var value = str.substring(1, 4); + var value = data[key]["x-mapping"] + if (value.length > 20) { + value = data[key]["x-mapping"].substring(0, 20) + "..." + } + cell.value = value + tr.appendChild(cell.createCell()) + + var cell:Cell = new Cell() + cell.child = true + cell.childType = "EDIT" + cell.value = "Edit" + var editTd = cell.createCell() + var editButton = (editTd.firstChild as HTMLInputElement) + editButton.addEventListener("click", function() { openPopUp("mapping", editButton) }) + editButton.setAttribute("id", key) + editButton.setAttribute("aria-label", "Edit " + data[key]["x-name"]) + tr.appendChild(editTd) + + rows.push(tr) + }); + + break + + case "settings": + alert() + break + + default: + console.log("Table content (menuKey):", menuKey); + + break + + } + + return rows + + } + + return +} + +class Cell { + child:Boolean + childType:string + value:any + className:string + tdClassName:string + imageURL:string + onclick:boolean + onclickHandler:() => void + + createCell():any { + var td = document.createElement("TD") + + + if (this.child == true) { + var element:any + + switch(this.childType){ + case "P": + element = document.createElement(this.childType); + element.textContent = this.value + element.className = this.className + break + + case "INPUT": + element = document.createElement(this.childType); + (element as HTMLInputElement).value = this.value; + (element as HTMLInputElement).type = "text"; + break + + case "INPUTCHANNEL": + element = document.createElement("INPUT"); + (element as HTMLInputElement).addEventListener("change", function() { changeChannelNumber(element) }); + (element as HTMLInputElement).value = this.value; + (element as HTMLInputElement).type = "text"; + break + + case "BULK": + element = document.createElement("INPUT"); + (element as HTMLInputElement).checked = this.value; + (element as HTMLInputElement).type = "checkbox"; + (element as HTMLInputElement).className = "bulk hideBulk"; + (element as HTMLInputElement).addEventListener("click", function(event) { + scheduleChannelRangeSelection((element as HTMLInputElement), (event as MouseEvent)) + }) + break + + case "BULK_HEAD": + element = document.createElement("INPUT"); + (element as HTMLInputElement).checked = this.value; + (element as HTMLInputElement).type = "checkbox"; + (element as HTMLInputElement).className = "bulk hideBulk"; + (element as HTMLInputElement).addEventListener("click", function() { selectAllChannels() }) + break + + case "IMG": + element = document.createElement(this.childType); + element.setAttribute("src", this.imageURL) + if (this.imageURL != "") { + // A broken logo falls back to no image; once, so the empty src cannot loop. + element.addEventListener("error", function() { element.setAttribute("src", "") }, {once: true}) + } + break + + case "EDIT": + element = document.createElement("INPUT"); + (element as HTMLInputElement).type = "button"; + (element as HTMLInputElement).value = this.value; + (element as HTMLInputElement).className = "mapping-edit-button"; + break + } + + td.appendChild(element) + + } else { + td.textContent = this.value + } + + if (this.onclick == true) { + td.addEventListener("click", this.onclickHandler) + td.className = "pointer" + } + + if (this.tdClassName != undefined) { + td.className = this.tdClassName + } + + return td + } + + return +} + +class ShowContent extends Content { + menuID:number + + constructor(menuID:number) { + super() + this.menuID = menuID + } + + createInput(type:string, name:string, value:string,):any { + + var input = document.createElement("INPUT") + input.setAttribute("type", type) + input.setAttribute("name", name) + input.setAttribute("value", value) + return input + } + + show():void { + COLUMN_TO_SORT = -1 + // Alten Inhalt löschen + var doc = document.getElementById(this.DocumentID) + doc.innerHTML = "" + showPreview(false) + + // Überschrift + var headline:string[] = menuItems[this.menuID].headline + + var menuKey = menuItems[this.menuID].menuKey + var h = this.createHeadline(headline) + doc.appendChild(h) + + var hr = this.createHR() + doc.appendChild(hr) + + // Interaktion + var div =this.createInteraction() + doc.appendChild(div) + var interaction = document.getElementById(this.interactionID) + switch (menuKey) { + case "playlist": + var input = this.createInput("button", menuKey, "New") + input.setAttribute("id", "-") + input.addEventListener("click", function() { openPopUp("playlist") }) + interaction.appendChild(input) + break; + + case "filter": + var input = this.createInput("button", menuKey, "New") + input.setAttribute("id", -1) + input.addEventListener("click", function(event) { openPopUp("filter", event.currentTarget) }) + interaction.appendChild(input) + break; + + + case "xmltv": + var input = this.createInput("button", menuKey, "New") + input.setAttribute("id", "xmltv") + input.addEventListener("click", function() { openPopUp("xmltv") }) + interaction.appendChild(input) + break; + + case "users": + var input = this.createInput("button", menuKey, "New") + input.setAttribute("id", "users") + input.addEventListener("click", function() { openPopUp("users") }) + interaction.appendChild(input) + break; + + case "mapping": + showElement("loading", true) + var input = this.createInput("button", menuKey, "Save") + input.addEventListener("click", function() { savePopupData("mapping", "", false, 0) }) + interaction.appendChild(input) + + var input = this.createInput("button", menuKey, "Bulk Edit") + input.addEventListener("click", function() { bulkEdit() }) + interaction.appendChild(input) + + var input = this.createInput("search", "search", "") + input.setAttribute("id", "searchMapping") + input.setAttribute("placeholder", "Search") + input.className = "search" + input.addEventListener("input", function() { searchInMapping() }) + interaction.appendChild(input) + break; + + case "settings": + var input = this.createInput("button", menuKey, "Save") + input.addEventListener("click", function() { saveSettings() }) + interaction.appendChild(input) + + var input = this.createInput("button", menuKey, "Backup") + input.addEventListener("click", function() { backup() }) + interaction.appendChild(input) + + var input = this.createInput("button", menuKey, "Restore") + input.addEventListener("click", function() { restore() }) + interaction.appendChild(input) + + var wrapper = document.createElement("DIV") + wrapper.setAttribute("id", "box-wrapper") + doc.appendChild(wrapper) + + this.DivID = "content_settings" + var settings = this.createDIV() + wrapper.appendChild(settings) + + showSettings() + + return + break + + case "log": + var input = this.createInput("button", menuKey, "Reset Logs") + input.addEventListener("click", function() { resetLogs() }) + interaction.appendChild(input) + + var wrapper = document.createElement("DIV") + wrapper.setAttribute("id", "box-wrapper") + doc.appendChild(wrapper) + + this.DivID = "content_log" + var logs = this.createDIV() + wrapper.appendChild(logs) + + showLogs(true) + + return + break + + case "logout": + location.reload() + document.cookie = "Token= ; expires = Thu, 01 Jan 1970 00:00:00 GMT" + break + + default: + console.log("Show content (menuKey):", menuKey); + break; + } + + // Tabelle erstellen (falls benötigt) + var tableHeader:string[] = menuItems[this.menuID].tableHeader + if (tableHeader.length > 0) { + var wrapper = document.createElement("DIV") + doc.appendChild(wrapper) + wrapper.setAttribute("id", "box-wrapper") + + var table = this.createTABLE() + wrapper.appendChild(table) + + var header = this.createTableRow() + table.appendChild(header) + + // Kopfzeile der Tablle + tableHeader.forEach(element => { + var cell:Cell = new Cell() + cell.child = true + cell.childType = "P" + cell.value = element + if (element == "BULK") { + cell.childType = "BULK_HEAD"; + cell.value = false + } + + if (menuKey == "mapping") { + + if (element == "Ch. No.") { + cell.onclick = true + cell.onclickHandler = function() { sortTable(1) } + cell.tdClassName = "sortThis" + } + + if (element == "Channel Name") { + cell.onclick = true + cell.onclickHandler = function() { sortTable(3) } + } + + if (element == "Playlist") { + cell.onclick = true + cell.onclickHandler = function() { sortTable(4) } + } + + if (element == "Group Title") { + cell.onclick = true + cell.onclickHandler = function() { sortTable(5) } + } + + } + + header.appendChild(cell.createCell()) + }); + + table.appendChild(header) + + // Inhalt der Tabelle + var rows:any = this.createTableContent(menuKey) + rows.forEach(tr => { + table.appendChild(tr) + }); + + } + + switch (menuKey) { + case "mapping": + sortTable(1) + break; + + case "filter": + showPreview(true) + sortTable(0) + break + + default: + COLUMN_TO_SORT = -1 + sortTable(0) + break; + } + + showElement("loading", false) + } + +} + +function openThisMenu(element) { + var id = element.id + var content:ShowContent = new ShowContent(id) + setActiveMenu(id) + content.show() + closeLayoutMenuIfMobile() + calculateWrapperHeight() + + return +} diff --git a/ts/menu.ts b/ts/menu.ts new file mode 100644 index 0000000..26d2435 --- /dev/null +++ b/ts/menu.ts @@ -0,0 +1,414 @@ +class MainMenu { + DocumentID:string = "main-menu" + HTMLTag:string = "LI" + ImagePath:string = "img/" + + createIMG(src):any { + var element = document.createElement("IMG") + element.setAttribute("src", this.ImagePath + src) + return element + } + + createValue(value):any { + var element = document.createElement("P") + element.textContent = value + return element + } +} + +class MainMenuItem extends MainMenu { + menuKey:string + value:string + imgSrc:string + headline:string + id:string + tableHeader:string[] + + constructor(menuKey:string, value:string, image:string, headline:string) { + super() + this.menuKey = menuKey + this.value = value + this.imgSrc = image + this.headline = headline + } + + createItem():void { + var item = document.createElement("LI") + item.addEventListener("click", function() { openThisMenu(item) }) + item.setAttribute("id", this.id) + item.setAttribute("data-menu", this.menuKey) + var img = this.createIMG(this.imgSrc) + var value = this.createValue(this.value) + + item.appendChild(img) + item.appendChild(value) + + var doc = document.getElementById(this.DocumentID) + doc.appendChild(item) + + switch(this.menuKey) { + case "playlist": + this.tableHeader = ["Playlist", "Tuner", "Last Update", "Availability %", "Type", "Streams", "group-title %", "tvg-id %", "Unique ID %"] + break + + case "xmltv": + this.tableHeader = ["Guide", "Last Update", "Availability %", "Channels", "Programs"] + break + + case "filter": + this.tableHeader = ["Filter Name", "Filter Type", "Filter"] + break + + case "users": + this.tableHeader = ["Username", "Password", "WEB", "PMS", "M3U", "XML", "API"] + break + + case "mapping": + this.tableHeader = ["BULK", "Ch. No.", "Logo", "Channel Name", "Playlist", "Group Title", "XMLTV File", "XMLTV ID", "Edit"] + break + + } + + //console.log(this.menuKey, this.tableHeader); + + } +} + +var SHELL_LAYOUT_READY:boolean = false + +function setLayoutMenuState(open:boolean) { + if (document.body == null) { + return + } + + if (open == true) { + document.body.classList.add("menu-open") + } else { + document.body.classList.remove("menu-open") + } +} + +function toggleLayoutMenu() { + if (document.body == null) { + return + } + + var isOpen:boolean = document.body.classList.contains("menu-open") + setLayoutMenuState(!isOpen) +} + +function closeLayoutMenuIfMobile() { + if (window.innerWidth <= 900) { + setLayoutMenuState(false) + } +} + +function setActiveMenu(menuID:string) { + + ACTIVE_MENU_ID = menuID.toString() + var menu = document.getElementById("main-menu") + if (menu == null) { + return + } + + var items = menu.getElementsByTagName("LI") + for (var i = 0; i < items.length; i++) { + items[i].classList.remove("menu-active") + } + + var activeItem = document.getElementById(ACTIVE_MENU_ID) + var activeMenuKey:string = "" + if (activeItem != null) { + activeItem.classList.add("menu-active") + var menuKeyValue = activeItem.getAttribute("data-menu") + if (menuKeyValue != null) { + activeMenuKey = menuKeyValue + } + } + + if (document.body != null) { + if (activeMenuKey.length > 0) { + document.body.setAttribute("data-active-menu", activeMenuKey) + } else { + document.body.removeAttribute("data-active-menu") + } + + if (activeMenuKey == "log") { + document.body.classList.add("menu-log-focus") + } else { + document.body.classList.remove("menu-log-focus") + } + } +} + +function renderStatusCards() { + var wrapper = document.getElementById("status-cards") + if (wrapper == null || SERVER.hasOwnProperty("clientInfo") == false) { + return + } + + var info = SERVER["clientInfo"] + var errors:number = parseInt(info["errors"], 10) + var warnings:number = parseInt(info["warnings"], 10) + var cards:any[] = [ + {label: "Streams", value: info["streams"], tone: "ok"}, + {label: "EPG Source", value: info["epgSource"], tone: "neutral"}, + {label: "XEPG Channels", value: info["xepg"], tone: "ok"}, + {label: "Errors", value: info["errors"], tone: errors > 0 ? "error" : "ok"}, + {label: "Warnings", value: info["warnings"], tone: warnings > 0 ? "warn" : "ok"}, + {label: "DVR", value: info["DVR"], tone: "neutral"}, + ] + + wrapper.innerHTML = "" + cards.forEach(card => { + + var box = document.createElement("DIV") + box.className = "status-card status-card-" + card.tone + + var label = document.createElement("P") + label.className = "status-card-label" + label.innerText = card.label + + var value = document.createElement("P") + value.className = "status-card-value" + if (card.value == undefined || card.value == "") { + value.innerText = "-" + } else { + value.innerText = card.value + } + + box.appendChild(label) + box.appendChild(value) + wrapper.appendChild(box) + }); + +} + +function initShellLayout() { + + if (SHELL_LAYOUT_READY == true) { + return + } + + var toggle = document.getElementById("menu-toggle") + if (toggle != null) { + toggle.onclick = function() { + toggleLayoutMenu() + } + } + + var overlay = document.getElementById("layout-overlay") + if (overlay != null) { + overlay.onclick = function() { + setLayoutMenuState(false) + } + } + + document.addEventListener("keydown", function(event) { + + if (event.key == "Escape") { + setLayoutMenuState(false) + showElement("popup", false) + return + } + + if (event.key == "/") { + var target = event.target as HTMLElement + var onInput = target.tagName == "INPUT" || target.tagName == "TEXTAREA" || target.tagName == "SELECT" + + if (onInput == true) { + return + } + + var search = document.getElementById("searchMapping") + if (search != null) { + event.preventDefault(); + (search as HTMLInputElement).focus() + } + } + + }); + + setConnectionState("idle") + SHELL_LAYOUT_READY = true +} + +function shouldPollLogs():boolean { + if (document.hidden == true) { + return false + } + + if (document.getElementById("content_log") == null) { + return false + } + + if (ACTIVE_MENU_ID.length < 1) { + return false + } + + var activeItem = document.getElementById(ACTIVE_MENU_ID) + if (activeItem == null) { + return false + } + + return activeItem.getAttribute("data-menu") == "log" +} + +function PageReady() { + + initShellLayout() + + var server:Server = new Server("getServerConfig") + server.request(new Object()) + + var bootstrapAttempts:number = 0 + var maxBootstrapAttempts:number = 5 + var bootstrapTimer:number = window.setInterval(function() { + + if (SERVER.hasOwnProperty("clientInfo") == true) { + window.clearInterval(bootstrapTimer) + return + } + + if (WS.hasQueued("getServerConfig")) { + return + } + + bootstrapAttempts++ + var retryServer:Server = new Server("getServerConfig") + retryServer.request(new Object()) + + if (bootstrapAttempts >= maxBootstrapAttempts) { + window.clearInterval(bootstrapTimer) + } + + }, 3000) + + window.addEventListener("resize", function(){ + if (window.innerWidth > 900) { + setLayoutMenuState(false) + } + calculateWrapperHeight(); + }, true); + + setInterval(function(){ + if (shouldPollLogs() == false) { + return + } + updateLog() + }, 10000); + + + return +} + +function isClientInfoHttpURL(value:string):boolean { + return /^https?:\/\//i.test(value) +} + +function setClientInfoValue(key:string, value:any) { + var element = document.getElementById(key) + if (element == null) { + return + } + + var textValue = "" + if (value != undefined && value != null) { + textValue = String(value) + } + + if ((key == "m3u-url" || key == "xepg-url") && isClientInfoHttpURL(textValue)) { + element.innerHTML = "" + var anchor = document.createElement("a") + anchor.href = textValue + anchor.target = "_blank" + anchor.rel = "noopener noreferrer" + anchor.textContent = textValue + element.appendChild(anchor) + return + } + + element.textContent = textValue +} + +// menuItemVisible reports whether a menu entry applies to the current server state. +function menuItemVisible(item:MainMenuItem):boolean { + switch (item.menuKey) { + + case "users": + case "logout": + return SERVER["settings"]["authentication.web"] == true + + case "mapping": + case "xmltv": + return SERVER["clientInfo"]["epgSource"] == "XEPG" + + default: + return true + } +} + +// renderedMenuKeys lists the menu entries currently in the document, in order. +function renderedMenuKeys(menu:HTMLElement):string[] { + var keys:string[] = [] + var items = menu.getElementsByTagName("LI") + for (var i = 0; i < items.length; i++) { + keys.push(items[i].getAttribute("data-menu")) + } + return keys +} + +function createLayout() { + + // Client Info + var obj = SERVER["clientInfo"] + var keys = getObjKeys(obj); + for (var i = 0; i < keys.length; i++) { + + setClientInfoValue(keys[i], obj[keys[i]]) + + } + renderStatusCards() + + var menu = document.getElementById("main-menu") + if (menu == null) { + return + } + + // Menu: rebuilt only when the set of visible entries changes, so that + // keyboard focus inside the list survives routine server responses. + var visibleKeys:string[] = [] + for (let i = 0; i < menuItems.length; i++) { + menuItems[i].id = i + if (menuItemVisible(menuItems[i])) { + visibleKeys.push(menuItems[i].menuKey) + } + } + + if (visibleKeys.join(",") != renderedMenuKeys(menu).join(",")) { + menu.innerHTML = "" + for (let i = 0; i < menuItems.length; i++) { + if (menuItemVisible(menuItems[i])) { + menuItems[i].createItem() + } + } + } + + if (ACTIVE_MENU_ID.length > 0 && document.getElementById(ACTIVE_MENU_ID)) { + setActiveMenu(ACTIVE_MENU_ID) + } + + var content = document.getElementById("content") + if (ACTIVE_MENU_ID.length == 0 && content != null) { + + if (content.innerHTML.replace(/\\s/g, "").length == 0) { + var firstItem = menu.getElementsByTagName("LI")[0] as HTMLElement + if (firstItem != undefined) { + firstItem.click() + } + } + + } + + return +} diff --git a/ts/menu_ts.ts b/ts/menu_ts.ts deleted file mode 100644 index 012a55d..0000000 --- a/ts/menu_ts.ts +++ /dev/null @@ -1,2477 +0,0 @@ - -class MainMenu { - DocumentID:string = "main-menu" - HTMLTag:string = "LI" - ImagePath:string = "img/" - - createIMG(src):any { - var element = document.createElement("IMG") - element.setAttribute("src", this.ImagePath + src) - return element - } - - createValue(value):any { - var element = document.createElement("P") - element.textContent = value - return element - } -} - -class MainMenuItem extends MainMenu { - menuKey:string - value:string - imgSrc:string - headline:string - id:string - tableHeader:string[] - - constructor(menuKey:string, value:string, image:string, headline:string) { - super() - this.menuKey = menuKey - this.value = value - this.imgSrc = image - this.headline = headline - } - - createItem():void { - var item = document.createElement("LI") - item.setAttribute("onclick", "javascript: openThisMenu(this)") - item.setAttribute("id", this.id) - item.setAttribute("data-menu", this.menuKey) - var img = this.createIMG(this.imgSrc) - var value = this.createValue(this.value) - - item.appendChild(img) - item.appendChild(value) - - var doc = document.getElementById(this.DocumentID) - doc.appendChild(item) - - switch(this.menuKey) { - case "playlist": - this.tableHeader = ["Playlist", "Tuner", "Last Update", "Availability %", "Type", "Streams", "group-title %", "tvg-id %", "Unique ID %"] - break - - case "xmltv": - this.tableHeader = ["Guide", "Last Update", "Availability %", "Channels", "Programs"] - break - - case "filter": - this.tableHeader = ["Filter Name", "Filter Type", "Filter"] - break - - case "users": - this.tableHeader = ["Username", "Password", "WEB", "PMS", "M3U", "XML", "API"] - break - - case "mapping": - this.tableHeader = ["BULK", "Ch. No.", "Logo", "Channel Name", "Playlist", "Group Title", "XMLTV File", "XMLTV ID", "Edit"] - break - - } - - //console.log(this.menuKey, this.tableHeader); - - } -} - -class Content { - - DocumentID:string = "content" - TableID:string = "content_table" - DivID:string - headerClass:string = "content_table_header" - interactionID:string = "content-interaction" - - createHeadline(value):any { - var element = document.createElement("H3") - element.textContent = value - return element - } - - createHR():any { - var element = document.createElement("HR") - return element - } - - createInteraction():any { - var element = document.createElement("DIV") - element.setAttribute("id", this.interactionID) - return element - } - - createDIV():any { - var element = document.createElement("DIV") - element.id = this.DivID - return element - } - - createTABLE():any { - var element = document.createElement("TABLE") - element.id = this.TableID - return element - } - - createTableRow():any { - var element = document.createElement("TR") - element.className = this.headerClass - return element - } - - createTableContent(menuKey:string):string[] { - - var data = new Object() - var rows = new Array() - - switch(menuKey) { - case "playlist": - var fileTypes = new Array("m3u", "hdhr") - - fileTypes.forEach(fileType => { - - data = SERVER["settings"]["files"][fileType] - - var keys = getObjKeys(data) - - keys.forEach(key => { - var tr = document.createElement("TR") - tr.id = key - - tr.setAttribute('onclick', 'javascript: openPopUp("' + fileType + '", this)') - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["name"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - if (SERVER["settings"]["buffer"] != "-") { - cell.value = data[key]["tuner"] - } else { - cell.value = "-" - } - - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["last.update"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["provider.availability"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["type"].toUpperCase(); - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["compatibility"]["streams"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["compatibility"]["group.title"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["compatibility"]["tvg.id"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["compatibility"]["stream.id"] - tr.appendChild(cell.createCell()) - - rows.push(tr) - }); - - }); - break - - case "filter": - delete SERVER["settings"]["filter"][-1] - data = SERVER["settings"]["filter"] - var keys = getObjKeys(data) - keys.forEach(key => { - var tr = document.createElement("TR") - tr.id = key - - tr.setAttribute('onclick', 'javascript: openPopUp("' + data[key]["type"] + '", this)') - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["name"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - switch (data[key]["type"]) { - case "custom-filter": - cell.value = "Custom" - break; - - case "group-title": - cell.value = "Group" - break; - - default: - break; - } - - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["filter"] - tr.appendChild(cell.createCell()) - - rows.push(tr) - - }); - break - - case "xmltv": - var fileTypes = new Array("xmltv") - - fileTypes.forEach(fileType => { - - data = SERVER["settings"]["files"][fileType] - - var keys = getObjKeys(data) - - keys.forEach(key => { - var tr = document.createElement("TR") - - tr.id = key - tr.setAttribute('onclick', 'javascript: openPopUp("' + fileType + '", this)') - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["name"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["last.update"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["provider.availability"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["compatibility"]["xmltv.channels"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["compatibility"]["xmltv.programs"] - tr.appendChild(cell.createCell()) - - rows.push(tr) - }); - - }); - break - - case "users": - var fileTypes = new Array("users") - - fileTypes.forEach(fileType => { - data = SERVER[fileType] - - var keys = getObjKeys(data) - - keys.forEach(key => { - var tr = document.createElement("TR") - tr.id = key - tr.setAttribute('onclick', 'javascript: openPopUp("' + fileType + '", this)') - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["data"]["username"] - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = "******" - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - if (data[key]["data"]["authentication.web"] == true) { - cell.value = "✓" - } else { - cell.value = "-" - } - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - if (data[key]["data"]["authentication.pms"] == true) { - cell.value = "✓" - } else { - cell.value = "-" - } - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - if (data[key]["data"]["authentication.m3u"] == true) { - cell.value = "✓" - } else { - cell.value = "-" - } - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - if (data[key]["data"]["authentication.xml"] == true) { - cell.value = "✓" - } else { - cell.value = "-" - } - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - if (data[key]["data"]["authentication.api"] == true) { - cell.value = "✓" - } else { - cell.value = "-" - } - tr.appendChild(cell.createCell()) - - rows.push(tr) - }); - - }); - break - - case "mapping": - BULK_EDIT = false - createSearchObj() - checkUndo("epgMapping") - console.log("MAPPING") - data = SERVER["xepg"]["epgMapping"] - - var keys = getObjKeys(data) - keys.forEach(key => { - var tr = document.createElement("TR") - tr.id = key - - //tr.setAttribute('oncontextmenu', 'javascript: rightClick(this)') - - switch (data[key]["x-active"]) { - case true: - tr.className = "activeEPG" - break; - - case false: - tr.className = "notActiveEPG" - break; - } - - // Bulk - var cell:Cell = new Cell() - cell.child = true - cell.childType = "BULK" - cell.value = false - tr.appendChild(cell.createCell()) - - // Kanalnummer - var cell:Cell = new Cell() - cell.child = true - cell.childType = "INPUTCHANNEL" - cell.value = data[key]["x-channelID"] - //td.setAttribute('onclick', 'javascript: changeChannelNumber("' + key + '", this)') - tr.appendChild(cell.createCell()) - - // Logo - var cell:Cell = new Cell() - cell.child = true - cell.childType = "IMG" - cell.imageURL = data[key]["tvg-logo"] - tr.appendChild(cell.createCell()) - - // Kanalname - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.className = data[key]["x-category"] - cell.value = data[key]["x-name"] - tr.appendChild(cell.createCell()) - - - // Playlist - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - //cell.value = data[key]["_file.m3u.name"] - cell.value = getValueFromProviderFile(data[key]["_file.m3u.id"], "m3u", "name") - tr.appendChild(cell.createCell()) - - - // Gruppe (group-title) - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = data[key]["x-group-title"] - tr.appendChild(cell.createCell()) - - // XMLTV Datei - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - - if (data[key]["x-xmltv-file"] != "-") { - cell.value = getValueFromProviderFile(data[key]["x-xmltv-file"], "xmltv", "name") - } else { - cell.value = data[key]["x-xmltv-file"] - } - - tr.appendChild(cell.createCell()) - - // XMLTV Kanal - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - //var value = str.substring(1, 4); - var value = data[key]["x-mapping"] - if (value.length > 20) { - value = data[key]["x-mapping"].substring(0, 20) + "..." - } - cell.value = value - tr.appendChild(cell.createCell()) - - var cell:Cell = new Cell() - cell.child = true - cell.childType = "EDIT" - cell.value = "Edit" - var editTd = cell.createCell() - var editButton = (editTd.firstChild as HTMLInputElement) - editButton.setAttribute('onclick', 'javascript: openPopUp("mapping", this)') - editButton.setAttribute("id", key) - editButton.setAttribute("aria-label", "Edit " + data[key]["x-name"]) - tr.appendChild(editTd) - - rows.push(tr) - }); - - break - - case "settings": - alert() - break - - default: - console.log("Table content (menuKey):", menuKey); - - break - - } - - return rows - - } - - return -} - -class Cell { - child:Boolean - childType:string - value:any - className:string - tdClassName:string - imageURL:string - onclick:boolean - onclickFunktion:string - - createCell():any { - var td = document.createElement("TD") - - - if (this.child == true) { - var element:any - - switch(this.childType){ - case "P": - element = document.createElement(this.childType); - element.textContent = this.value - element.className = this.className - break - - case "INPUT": - element = document.createElement(this.childType); - (element as HTMLInputElement).value = this.value; - (element as HTMLInputElement).type = "text"; - break - - case "INPUTCHANNEL": - element = document.createElement("INPUT"); - (element as HTMLInputElement).setAttribute("onchange", "javscript: changeChannelNumber(this)"); - (element as HTMLInputElement).value = this.value; - (element as HTMLInputElement).type = "text"; - break - - case "BULK": - element = document.createElement("INPUT"); - (element as HTMLInputElement).checked = this.value; - (element as HTMLInputElement).type = "checkbox"; - (element as HTMLInputElement).className = "bulk hideBulk"; - (element as HTMLInputElement).addEventListener("click", function(event) { - scheduleChannelRangeSelection((element as HTMLInputElement), (event as MouseEvent)) - }) - break - - case "BULK_HEAD": - element = document.createElement("INPUT"); - (element as HTMLInputElement).checked = this.value; - (element as HTMLInputElement).type = "checkbox"; - (element as HTMLInputElement).className = "bulk hideBulk"; - (element as HTMLInputElement).setAttribute("onclick", "javascript: selectAllChannels()") - break - - case "IMG": - element = document.createElement(this.childType); - element.setAttribute("src", this.imageURL) - if (this.imageURL != "") { - element.setAttribute("onerror", "javascript: this.onerror=null;this.src=''" ) - //onerror="this.onerror=null;this.src='missing.gif';" - } - break - - case "EDIT": - element = document.createElement("INPUT"); - (element as HTMLInputElement).type = "button"; - (element as HTMLInputElement).value = this.value; - (element as HTMLInputElement).className = "mapping-edit-button"; - break - } - - td.appendChild(element) - - } else { - td.textContent = this.value - } - - if (this.onclick == true) { - td.setAttribute("onclick", this.onclickFunktion) - td.className = "pointer" - } - - if (this.tdClassName != undefined) { - td.className = this.tdClassName - } - - return td - } - - return -} - -class ShowContent extends Content { - menuID:number - - constructor(menuID:number) { - super() - this.menuID = menuID - } - - createInput(type:string, name:string, value:string,):any { - - var input = document.createElement("INPUT") - input.setAttribute("type", type) - input.setAttribute("name", name) - input.setAttribute("value", value) - return input - } - - show():void { - COLUMN_TO_SORT = -1 - // Alten Inhalt löschen - var doc = document.getElementById(this.DocumentID) - doc.innerHTML = "" - showPreview(false) - - // Überschrift - var headline:string[] = menuItems[this.menuID].headline - - var menuKey = menuItems[this.menuID].menuKey - var h = this.createHeadline(headline) - doc.appendChild(h) - - var hr = this.createHR() - doc.appendChild(hr) - - // Interaktion - var div =this.createInteraction() - doc.appendChild(div) - var interaction = document.getElementById(this.interactionID) - switch (menuKey) { - case "playlist": - var input = this.createInput("button", menuKey, "New") - input.setAttribute("id", "-") - input.setAttribute("onclick", 'javascript: openPopUp("playlist")') - interaction.appendChild(input) - break; - - case "filter": - var input = this.createInput("button", menuKey, "New") - input.setAttribute("id", -1) - input.setAttribute("onclick", 'javascript: openPopUp("filter", this)') - interaction.appendChild(input) - break; - - - case "xmltv": - var input = this.createInput("button", menuKey, "New") - input.setAttribute("id", "xmltv") - input.setAttribute("onclick", 'javascript: openPopUp("xmltv")') - interaction.appendChild(input) - break; - - case "users": - var input = this.createInput("button", menuKey, "New") - input.setAttribute("id", "users") - input.setAttribute("onclick", 'javascript: openPopUp("users")') - interaction.appendChild(input) - break; - - case "mapping": - showElement("loading", true) - var input = this.createInput("button", menuKey, "Save") - input.setAttribute("onclick", 'javascript: savePopupData("mapping", "", "")') - interaction.appendChild(input) - - var input = this.createInput("button", menuKey, "Bulk Edit") - input.setAttribute("onclick", 'javascript: bulkEdit()') - interaction.appendChild(input) - - var input = this.createInput("search", "search", "") - input.setAttribute("id", "searchMapping") - input.setAttribute("placeholder", "Search") - input.className = "search" - input.setAttribute("oninput", 'javascript: searchInMapping()') - interaction.appendChild(input) - break; - - case "settings": - var input = this.createInput("button", menuKey, "Save") - input.setAttribute("onclick", 'javascript: saveSettings();') - interaction.appendChild(input) - - var input = this.createInput("button", menuKey, "Backup") - input.setAttribute("onclick", 'javascript: backup();') - interaction.appendChild(input) - - var input = this.createInput("button", menuKey, "Restore") - input.setAttribute("onclick", 'javascript: restore();') - interaction.appendChild(input) - - var wrapper = document.createElement("DIV") - wrapper.setAttribute("id", "box-wrapper") - doc.appendChild(wrapper) - - this.DivID = "content_settings" - var settings = this.createDIV() - wrapper.appendChild(settings) - - showSettings() - - return - break - - case "log": - var input = this.createInput("button", menuKey, "Reset Logs") - input.setAttribute("onclick", 'javascript: resetLogs();') - interaction.appendChild(input) - - var wrapper = document.createElement("DIV") - wrapper.setAttribute("id", "box-wrapper") - doc.appendChild(wrapper) - - this.DivID = "content_log" - var logs = this.createDIV() - wrapper.appendChild(logs) - - showLogs(true) - - return - break - - case "logout": - location.reload() - document.cookie = "Token= ; expires = Thu, 01 Jan 1970 00:00:00 GMT" - break - - default: - console.log("Show content (menuKey):", menuKey); - break; - } - - // Tabelle erstellen (falls benötigt) - var tableHeader:string[] = menuItems[this.menuID].tableHeader - if (tableHeader.length > 0) { - var wrapper = document.createElement("DIV") - doc.appendChild(wrapper) - wrapper.setAttribute("id", "box-wrapper") - - var table = this.createTABLE() - wrapper.appendChild(table) - - var header = this.createTableRow() - table.appendChild(header) - - // Kopfzeile der Tablle - tableHeader.forEach(element => { - var cell:Cell = new Cell() - cell.child = true - cell.childType = "P" - cell.value = element - if (element == "BULK") { - cell.childType = "BULK_HEAD"; - cell.value = false - } - - if (menuKey == "mapping") { - - if (element == "Ch. No.") { - cell.onclick = true - cell.onclickFunktion = "javascript: sortTable(1);" - cell.tdClassName = "sortThis" - } - - if (element == "Channel Name") { - cell.onclick = true - cell.onclickFunktion = "javascript: sortTable(3);" - } - - if (element == "Playlist") { - cell.onclick = true - cell.onclickFunktion = "javascript: sortTable(4);" - } - - if (element == "Group Title") { - cell.onclick = true - cell.onclickFunktion = "javascript: sortTable(5);" - } - - } - - header.appendChild(cell.createCell()) - }); - - table.appendChild(header) - - // Inhalt der Tabelle - var rows:any = this.createTableContent(menuKey) - rows.forEach(tr => { - table.appendChild(tr) - }); - - } - - switch (menuKey) { - case "mapping": - sortTable(1) - break; - - case "filter": - showPreview(true) - sortTable(0) - break - - default: - COLUMN_TO_SORT = -1 - sortTable(0) - break; - } - - showElement("loading", false) - } - -} - -var SHELL_LAYOUT_READY:boolean = false - -function setLayoutMenuState(open:boolean) { - if (document.body == null) { - return - } - - if (open == true) { - document.body.classList.add("menu-open") - } else { - document.body.classList.remove("menu-open") - } -} - -function toggleLayoutMenu() { - if (document.body == null) { - return - } - - var isOpen:boolean = document.body.classList.contains("menu-open") - setLayoutMenuState(!isOpen) -} - -function closeLayoutMenuIfMobile() { - if (window.innerWidth <= 900) { - setLayoutMenuState(false) - } -} - -function setActiveMenu(menuID:string) { - - ACTIVE_MENU_ID = menuID.toString() - var menu = document.getElementById("main-menu") - if (menu == null) { - return - } - - var items = menu.getElementsByTagName("LI") - for (var i = 0; i < items.length; i++) { - items[i].classList.remove("menu-active") - } - - var activeItem = document.getElementById(ACTIVE_MENU_ID) - var activeMenuKey:string = "" - if (activeItem != null) { - activeItem.classList.add("menu-active") - var menuKeyValue = activeItem.getAttribute("data-menu") - if (menuKeyValue != null) { - activeMenuKey = menuKeyValue - } - } - - if (document.body != null) { - if (activeMenuKey.length > 0) { - document.body.setAttribute("data-active-menu", activeMenuKey) - } else { - document.body.removeAttribute("data-active-menu") - } - - if (activeMenuKey == "log") { - document.body.classList.add("menu-log-focus") - } else { - document.body.classList.remove("menu-log-focus") - } - } -} - -function renderStatusCards() { - var wrapper = document.getElementById("status-cards") - if (wrapper == null || SERVER.hasOwnProperty("clientInfo") == false) { - return - } - - var info = SERVER["clientInfo"] - var errors:number = parseInt(info["errors"], 10) - var warnings:number = parseInt(info["warnings"], 10) - var cards:any[] = [ - {label: "Streams", value: info["streams"], tone: "ok"}, - {label: "EPG Source", value: info["epgSource"], tone: "neutral"}, - {label: "XEPG Channels", value: info["xepg"], tone: "ok"}, - {label: "Errors", value: info["errors"], tone: errors > 0 ? "error" : "ok"}, - {label: "Warnings", value: info["warnings"], tone: warnings > 0 ? "warn" : "ok"}, - {label: "DVR", value: info["DVR"], tone: "neutral"}, - ] - - wrapper.innerHTML = "" - cards.forEach(card => { - - var box = document.createElement("DIV") - box.className = "status-card status-card-" + card.tone - - var label = document.createElement("P") - label.className = "status-card-label" - label.innerText = card.label - - var value = document.createElement("P") - value.className = "status-card-value" - if (card.value == undefined || card.value == "") { - value.innerText = "-" - } else { - value.innerText = card.value - } - - box.appendChild(label) - box.appendChild(value) - wrapper.appendChild(box) - }); - -} - -function initShellLayout() { - - if (SHELL_LAYOUT_READY == true) { - return - } - - var toggle = document.getElementById("menu-toggle") - if (toggle != null) { - toggle.onclick = function() { - toggleLayoutMenu() - } - } - - var overlay = document.getElementById("layout-overlay") - if (overlay != null) { - overlay.onclick = function() { - setLayoutMenuState(false) - } - } - - document.addEventListener("keydown", function(event) { - - if (event.key == "Escape") { - setLayoutMenuState(false) - showElement("popup", false) - return - } - - if (event.key == "/") { - var target = event.target as HTMLElement - var onInput = target.tagName == "INPUT" || target.tagName == "TEXTAREA" || target.tagName == "SELECT" - - if (onInput == true) { - return - } - - var search = document.getElementById("searchMapping") - if (search != null) { - event.preventDefault(); - (search as HTMLInputElement).focus() - } - } - - }); - - setConnectionState("idle") - SHELL_LAYOUT_READY = true -} - -function shouldPollLogs():boolean { - if (document.hidden == true) { - return false - } - - if (document.getElementById("content_log") == null) { - return false - } - - if (ACTIVE_MENU_ID.length < 1) { - return false - } - - var activeItem = document.getElementById(ACTIVE_MENU_ID) - if (activeItem == null) { - return false - } - - return activeItem.getAttribute("data-menu") == "log" -} - -function PageReady() { - - initShellLayout() - - var server:Server = new Server("getServerConfig") - server.request(new Object()) - - var bootstrapAttempts:number = 0 - var maxBootstrapAttempts:number = 5 - var bootstrapTimer:number = window.setInterval(function() { - - if (SERVER.hasOwnProperty("clientInfo") == true) { - window.clearInterval(bootstrapTimer) - return - } - - if (WS.hasQueued("getServerConfig")) { - return - } - - bootstrapAttempts++ - var retryServer:Server = new Server("getServerConfig") - retryServer.request(new Object()) - - if (bootstrapAttempts >= maxBootstrapAttempts) { - window.clearInterval(bootstrapTimer) - } - - }, 3000) - - window.addEventListener("resize", function(){ - if (window.innerWidth > 900) { - setLayoutMenuState(false) - } - calculateWrapperHeight(); - }, true); - - setInterval(function(){ - if (shouldPollLogs() == false) { - return - } - updateLog() - }, 10000); - - - return -} - -function isClientInfoHttpURL(value:string):boolean { - return /^https?:\/\//i.test(value) -} - -function setClientInfoValue(key:string, value:any) { - var element = document.getElementById(key) - if (element == null) { - return - } - - var textValue = "" - if (value != undefined && value != null) { - textValue = String(value) - } - - if ((key == "m3u-url" || key == "xepg-url") && isClientInfoHttpURL(textValue)) { - element.innerHTML = "" - var anchor = document.createElement("a") - anchor.href = textValue - anchor.target = "_blank" - anchor.rel = "noopener noreferrer" - anchor.textContent = textValue - element.appendChild(anchor) - return - } - - element.textContent = textValue -} - -function createLayout() { - - // Client Info - var obj = SERVER["clientInfo"] - var keys = getObjKeys(obj); - for (var i = 0; i < keys.length; i++) { - - setClientInfoValue(keys[i], obj[keys[i]]) - - } - renderStatusCards() - - if (!document.getElementById("main-menu")) { - return - } - - - - // Menü erstellen - document.getElementById("main-menu").innerHTML = "" - for (let i = 0; i < menuItems.length; i++) { - - menuItems[i].id = i - - switch (menuItems[i]["menuKey"]) { - - case "users": - case "logout": - if (SERVER["settings"]["authentication.web"] == true) { - menuItems[i].createItem() - } - break - - case "mapping": - case "xmltv": - if (SERVER["clientInfo"]["epgSource"] == "XEPG") { - menuItems[i].createItem() - } - break - - default: - menuItems[i].createItem() - break - } - - } - - if (ACTIVE_MENU_ID.length > 0 && document.getElementById(ACTIVE_MENU_ID)) { - setActiveMenu(ACTIVE_MENU_ID) - } - - var content = document.getElementById("content") - var menu = document.getElementById("main-menu") - if (ACTIVE_MENU_ID.length == 0 && content != null && menu != null) { - - if (content.innerHTML.replace(/\\s/g, "").length == 0) { - var firstItem = menu.getElementsByTagName("LI")[0] as HTMLElement - if (firstItem != undefined) { - firstItem.click() - } - } - - } - - return -} - -function openThisMenu(element) { - var id = element.id - var content:ShowContent = new ShowContent(id) - setActiveMenu(id) - content.show() - closeLayoutMenuIfMobile() - calculateWrapperHeight() - - return -} - -class PopupWindow { - DocumentID:string = "popup-custom" - InteractionID:string = "interaction" - doc = document.getElementById(this.DocumentID) - - createTitle(title:string):any { - var td = document.createElement("TD") - td.className = "left" - td.innerHTML = title + ":" - return td - } - - createContent(element):any { - var td = document.createElement("TD") - td.appendChild(element) - return td - } - - createInteraction():any { - var div = document.createElement("div") - div.setAttribute("id", "popup-interaction") - div.className = "interaction" - this.doc.appendChild(div) - } -} - -class PopupContent extends PopupWindow{ - - table = document.createElement("TABLE") - - createHeadline(headline):void { - this.doc.innerHTML = "" - var element = document.createElement("H3") - element.innerHTML = headline.toUpperCase() - this.doc.appendChild(element) - - // Tabelle erstellen - this.table = document.createElement("TABLE") - this.doc.appendChild(this.table) - } - - appendRow(title:string, element:any):void { - var tr = document.createElement("TR") - - // Bezeichnung - if (title.length != 0) { - tr.appendChild(this.createTitle(title)) - } - - - // Content - tr.appendChild(this.createContent(element)) - this.table.appendChild(tr) - } - - - createInput(type:string, name:string, value:string):any { - - var input = document.createElement("INPUT") - if (value == undefined) { - value = "" - } - - input.setAttribute("type", type) - input.setAttribute("name", name) - input.setAttribute("value", value) - return input - } - - createCheckbox(name:string):any { - var input = document.createElement("INPUT") - - input.setAttribute("type", "checkbox") - input.setAttribute("name", name) - return input - } - - createSelect(text:string[], values:string[], set:string, dbKey:string):any { - var select = document.createElement("SELECT") - select.setAttribute("name", dbKey) - for (let i = 0; i < text.length; i++) { - var option = document.createElement("OPTION") - option.setAttribute("value", values[i]) - option.innerText = text[i] - select.appendChild(option) - } - if(set != "") { - (select as HTMLSelectElement).value = set - } - - if (set == undefined) { - (select as HTMLSelectElement).value = values[0] - } - - return select - } - - selectOption(select:any, value:string):any { - //select.selectedOptions = value - var s:HTMLSelectElement = (select as HTMLSelectElement) - s.options[s.selectedIndex].value = value - return select - } - - // isHTML must only be true for static text written in the source; never for data. - description(value:string, isHTML:boolean = false):any { - var tr = document.createElement("TR") - var td = document.createElement("TD") - var span = document.createElement("PRE") - - if (isHTML) { - span.innerHTML = value - } else { - span.textContent = value - } - - tr.appendChild(td) - - tr.appendChild(this.createContent(span)) - - this.table.appendChild(tr) - } - - // Interaktion - addInteraction(element:any) { - var interaction = document.getElementById("popup-interaction") - interaction.appendChild(element) - } -} - -function openPopUp(dataType, element) { - - var data:object = new Object(); - var id:any - switch (element) { - case undefined: - - switch (dataType) { - case "group-title": - if (id == undefined) { - id = -1 - } - data = getLocalData("filter", id) - data["type"] = "group-title" - break; - - case "custom-filter": - if (id == undefined) { - id = -1 - } - data = getLocalData("filter", id) - data["type"] = "custom-filter" - break; - - default: - data["id.provider"] = "-" - data["type"] = dataType - id = "-" - break; - } - - break - - default: - id = element.id - data = getLocalData(dataType, id) - break; - } - - var content:PopupContent = new PopupContent() - - switch (dataType) { - case "playlist": - content.createHeadline("Playlist type") - // Type - var text:string[] = ["M3U", "HDHomeRun"] - var values:string[] = ["javascript: openPopUp('m3u')", "javascript: openPopUp('hdhr')"] - var select = content.createSelect(text, values, "", "type") - select.setAttribute("id", "type") - select.setAttribute("onchange", 'javascript: changeButtonAction(this, "next", "onclick")') // changeButtonAction - content.appendRow("Type", select) - - // Interaktion - content.createInteraction() - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Weiter - var input = content.createInput("button", "next", "Next") - input.setAttribute("onclick", 'javascript: openPopUp("m3u")') - input.setAttribute("id", 'next') - content.addInteraction(input) - break - - case "m3u": - content.createHeadline(dataType) - // Name - var dbKey:string = "name" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Playlist name") - content.appendRow("Name", input) - - // Beschreibung - var dbKey:string = "description" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Description") - content.appendRow("Description", input) - - // URL - var dbKey:string = "file.source" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "File path or URL of the M3U") - content.appendRow("M3U File", input) - - // Tuner - if (SERVER["settings"]["buffer"] != "-") { - var text:string[] = new Array() - var values:string[] = new Array() - - for (var i = 1; i <= 100; i++) { - text.push(i.toString()) - values.push(i.toString()) - } - - var dbKey:string = "tuner" - var select = content.createSelect(text, values, data[dbKey], dbKey) - select.setAttribute("onfocus", "javascript: return;") - content.appendRow("Tuner / Streams", select) - } else { - var dbKey:string = "tuner" - if (data[dbKey] == undefined) { - data[dbKey] = 1 - } - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("readonly", "true") - input.className = "notAvailable" - content.appendRow("Tuner / Streams", input) - } - - content.description("Number of parallel connections that can be established to the provider.
Only available with activated buffer.
New settings will only be applied after quitting all streams.", true) - - // Interaktion - content.createInteraction() - // Löschen - if (data["id.provider"]!= "-") { - var input = content.createInput("button", "delete", "Delete") - input.className = "delete" - input.setAttribute('onclick', 'javascript: savePopupData("m3u", "' + id + '", true, 0)') - content.addInteraction(input) - } else { - var input = content.createInput("button", "back", "Back") - input.setAttribute("onclick", 'javascript: openPopUp("playlist")') - content.addInteraction(input) - } - - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Aktualisieren - if (data["id.provider"]!= "-") { - var input = content.createInput("button", "update", "Update") - input.setAttribute('onclick', 'javascript: savePopupData("m3u", "' + id + '", false, 1)') - content.addInteraction(input) - } - - // Speichern - var input = content.createInput("button", "save", "Save") - input.setAttribute('onclick', 'javascript: savePopupData("m3u", "' + id + '", false, 0)') - content.addInteraction(input) - break - - case "hdhr": - content.createHeadline(dataType) - // Name - var dbKey:string = "name" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Playlist name") - content.appendRow("Name", input) - - // Beschreibung - var dbKey:string = "description" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Description") - content.appendRow("Description", input) - - // URL - var dbKey:string = "file.source" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "IP address and port (192.168.1.10:5004)") - content.appendRow("HDHomeRun IP", input) - - // Tuner - if (SERVER["settings"]["buffer"] != "-") { - var text:string[] = new Array() - var values:string[] = new Array() - - for (var i = 1; i <= 100; i++) { - text.push(i.toString()) - values.push(i.toString()) - } - - var dbKey:string = "tuner" - var select = content.createSelect(text, values, data[dbKey], dbKey) - select.setAttribute("onfocus", "javascript: return;") - content.appendRow("Tuner / Streams", select) - } else { - var dbKey:string = "tuner" - if (data[dbKey] == undefined) { - data[dbKey] = 1 - } - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("readonly", "true") - input.className = "notAvailable" - content.appendRow("Tuner / Streams", input) - } - - content.description("Number of parallel connections that can be established to the provider.
Only available with activated buffer.
New settings will only be applied after quitting all streams.", true) - - // Interaktion - content.createInteraction() - // Löschen - if (data["id.provider"]!= "-") { - var input = content.createInput("button", "delete", "Delete") - input.setAttribute('onclick', 'javascript: savePopupData("hdhr", "' + id + '", true, 0)') - input.className = "delete" - content.addInteraction(input) - } else { - var input = content.createInput("button", "back", "Back") - input.setAttribute("onclick", 'javascript: openPopUp("playlist")') - content.addInteraction(input) - } - - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Aktualisieren - if (data["id.provider"]!= "-") { - var input = content.createInput("button", "update", "Update") - input.setAttribute('onclick', 'javascript: savePopupData("hdhr", "' + id + '", false, 1)') - content.addInteraction(input) - } - - // Speichern - var input = content.createInput("button", "save", "Save") - input.setAttribute('onclick', 'javascript: savePopupData("hdhr", "' + id + '", false, 0)') - content.addInteraction(input) - break - - case "filter": - content.createHeadline(dataType) - - // Type - var dbKey:string = "type" - var text:string[] = ["M3U: " + "Group Title", "xTeVe: " + "Custom Filter"] - var values:string[] = ["javascript: openPopUp('group-title')", "javascript: openPopUp('custom-filter')"] - var select = content.createSelect(text, values, "javascript: openPopUp('group-title')", dbKey) - select.setAttribute("id", id) - select.setAttribute("onchange", 'javascript: changeButtonAction(this, "next", "onclick");') // changeButtonAction - content.appendRow("Type", select) - - // Interaktion - content.createInteraction() - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Weiter - var input = content.createInput("button", "next", "Next") - input.setAttribute("onclick", 'javascript: openPopUp("group-title")') - input.setAttribute("id", 'next') - content.addInteraction(input) - break - - case "custom-filter": - case "group-title": - - switch (dataType) { - case "custom-filter": - content.createHeadline("Custom") - break; - - case "group-title": - content.createHeadline("Group") - break; - } - - // Name - var dbKey:string = "name" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Filter name") - content.appendRow("Filter Name", input) - - // Beschreibung - var dbKey:string = "description" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Description") - content.appendRow("Description", input) - - // Typ - var dbKey:string = "type" - var input = content.createInput("hidden", dbKey, data[dbKey]) - content.appendRow("", input) - - var filterType = data[dbKey] - - switch (filterType) { - - case "custom-filter": - // Groß- Kleinschreibung beachten - var dbKey:string = "caseSensitive" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - content.appendRow("Case Sensitive", input) - - // Filterregel (Benutzerdefiniert) - var dbKey:string = "filter" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Sport {HD} !{ES,IT}") - content.appendRow("Filter Rule", input) - - break; - - case "group-title": - //alert(dbKey + " " + filterType) - // Filter basierend auf den Gruppen in der M3U - var dbKey:string = "filter" - var groupsM3U = getLocalData("m3uGroups", "") - var text:string[] = groupsM3U["text"] - var values:string[] = groupsM3U["value"] - - var select = content.createSelect(text, values, data[dbKey], dbKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - content.appendRow("Group Title", select) - content.description("Select a M3U group. (Counter)
Changing the group title in the M3U invalidates the filter.", true) - - // Groß- Kleinschreibung beachten - var dbKey:string = "caseSensitive" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - content.appendRow("Case Sensitive", input) - - - var dbKey:string = "include" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "FHD,UHD") - - content.appendRow("Include", input) - content.description("Channel name must include.
(Comma separated) Comma means or", true) - - var dbKey:string = "exclude" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "ES,IT") - content.appendRow("Exclude", input) - content.description("Channel name must not contain.
(Comma separated) Comma means or", true) - - break - - default: - break; - } - - // Interaktion - content.createInteraction() - - // Löschen - var input = content.createInput("button", "delete", "Delete") - input.setAttribute('onclick', 'javascript: savePopupData("filter", "' + id + '", true, 0)') - input.className = "delete" - content.addInteraction(input) - - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Speichern - var input = content.createInput("button", "save", "Save") - input.setAttribute('onclick', 'javascript: savePopupData("filter", "' + id + '", false, 0)') - content.addInteraction(input) - - break - - case "xmltv": - content.createHeadline(dataType) - // Name - var dbKey:string = "name" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Guide name") - content.appendRow("Name", input) - - // Beschreibung - var dbKey:string = "description" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Description") - content.appendRow("Description", input) - - // URL - var dbKey:string = "file.source" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "File path or URL of the XMLTV") - content.appendRow("XMLTV File", input) - - // Interaktion - content.createInteraction() - // Löschen - if (data["id.provider"]!= "-") { - var input = content.createInput("button", "delete", "Delete") - input.setAttribute('onclick', 'javascript: savePopupData("xmltv", "' + id + '", true, 0)') - input.className = "delete" - content.addInteraction(input) - } - - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Aktualisieren - if (data["id.provider"]!= "-") { - var input = content.createInput("button", "update", "Update") - input.setAttribute('onclick', 'javascript: savePopupData("xmltv", "' + id + '", false, 1)') - content.addInteraction(input) - } - - // Speichern - var input = content.createInput("button", "save", "Save") - input.setAttribute('onclick', 'javascript: savePopupData("xmltv", "' + id + '", false, 0)') - content.addInteraction(input) - break - - case "users": - content.createHeadline("Users") - // Benutzername - var dbKey:string = "username" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Username") - content.appendRow("Username", input) - - // Neues Passwort - var dbKey:string = "password" - var input = content.createInput("password", dbKey, "") - input.setAttribute("placeholder", "Password") - content.appendRow("Password", input) - - // Bestätigung - var dbKey:string = "confirm" - var input = content.createInput("password", dbKey, "") - input.setAttribute("placeholder", "Password confirm") - content.appendRow("Confirm", input) - - // Berechtigung WEB - var dbKey:string = "authentication.web" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - if (data["defaultUser"] == true) { - input.setAttribute("onclick", "javascript: return false") - } - content.appendRow("Web Access", input) - - // Berechtigung PMS - var dbKey:string = "authentication.pms" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - content.appendRow("PMS Access", input) - - // Berechtigung M3U - var dbKey:string = "authentication.m3u" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - content.appendRow("M3U Access", input) - - // Berechtigung XML - var dbKey:string = "authentication.xml" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - content.appendRow("XML Access", input) - - // Berechtigung API - var dbKey:string = "authentication.api" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - content.appendRow("API Access", input) - - // Interaktion - content.createInteraction() - - // Löschen - if (data["defaultUser"]!= true && id != "-") { - var input = content.createInput("button", "delete", "Delete") - input.className = "delete" - input.setAttribute('onclick', 'javascript: savePopupData("' + dataType + '", "' + id + '", true, 0)') - content.addInteraction(input) - } - - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Speichern - var input = content.createInput("button", "save", "Save") - input.setAttribute("onclick", 'javascript: savePopupData("' + dataType + '", "' + id + '", "false");') - content.addInteraction(input) - - break - - case "mapping": - content.createHeadline("Mapping") - // Aktiv - var dbKey:string = "x-active" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - input.id = "active" - //input.setAttribute("onchange", "javascript: this.className = 'changed'") - input.setAttribute("onchange", "javascript: toggleChannelStatus('" + id + "', this)") - content.appendRow("Active", input) - - // Kanalname - var dbKey:string = "x-name" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("onchange", "javascript: this.className = 'changed'") - if (BULK_EDIT == true) { - input.style.border = "solid 1px red" - input.setAttribute("readonly", "true") - } - content.appendRow("Channel Name", input) - - content.description(data["name"]) - - // Beschreibung - var dbKey:string = "x-description" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("placeholder", "Used by the Dummy as an XML description") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - content.appendRow("Channel Description", input) - - // Aktualisierung des Kanalnamens - if (data.hasOwnProperty("_uuid.key")) { - if (data["_uuid.key"] != "") { - var dbKey:string = "x-update-channel-name" - var input = content.createCheckbox(dbKey) - input.setAttribute("onchange", "javascript: this.className = 'changed'") - input.checked = data[dbKey] - content.appendRow("Update Channel Name", input) - } - } - - // Logo URL (Kanal) - var dbKey:string = "tvg-logo" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("onchange", "javascript: this.className = 'changed'") - input.setAttribute("id", "channel-icon") - content.appendRow("Logo URL", input) - - // Aktualisierung des Kanallogos - var dbKey:string = "x-update-channel-icon" - var input = content.createCheckbox(dbKey) - input.checked = data[dbKey] - input.setAttribute("id", "update-icon") - input.setAttribute("onchange", "javascript: this.className = 'changed'; changeChannelLogo('" + id + "');") - content.appendRow("Update Channel Logo", input) - - // Erweitern der EPG Kategorie - var dbKey:string = "x-category" - var text:string[] = ["-", "Kids (Emby only)", "News", "Movie", "Series", "Sports"] - var values:string[] = ["", "Kids", "News", "Movie", "Series", "Sports"] - var select = content.createSelect(text, values, data[dbKey], dbKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - content.appendRow("EPG Category", select) - - // M3U Gruppentitel - var dbKey:string = "x-group-title" - var input = content.createInput("text", dbKey, data[dbKey]) - input.setAttribute("onchange", "javascript: this.className = 'changed'") - content.appendRow("Group Title (xteve.m3u)", input) - - if (data["group-title"] != undefined) { - content.description(data["group-title"]) - } - - // XMLTV Datei - var dbKey:string = "x-xmltv-file" - var xmlFile = data[dbKey] - var xmltv:XMLTVFile = new XMLTVFile() - var select = xmltv.getFiles(data[dbKey]) - select.setAttribute("name", dbKey) - select.setAttribute("id", "popup-xmltv") - select.setAttribute("onchange", "javascript: this.className = 'changed'; setXmltvChannel('" + id + "',this);") - content.appendRow("XMLTV File", select) - var file = data[dbKey] - - // XMLTV Mapping - var dbKey:string = "x-mapping" - var xmltv:XMLTVFile = new XMLTVFile() - var select = xmltv.getPrograms(file, data[dbKey]) - select.setAttribute("name", dbKey) - select.setAttribute("id", "popup-mapping") - select.setAttribute("onchange", "javascript: this.className = 'changed'; checkXmltvChannel('" + id + "',this,'" + xmlFile + "');") - - sortSelect(select) - content.appendRow("XMLTV Channel", select) - - // Interaktion - content.createInteraction() - - // Logo hochladen - var input = content.createInput("button", "cancel", "Upload Logo") - input.setAttribute("onclick", 'javascript: uploadLogo();') - content.addInteraction(input) - - // Abbrechen - var input = content.createInput("button", "cancel", "Cancel") - input.setAttribute("onclick", 'javascript: showElement("popup", false);') - content.addInteraction(input) - - // Fertig - var ids:string[] = new Array() - ids = getAllSelectedChannels() - if (ids.length == 0) { - ids.push(id) - } - - var input = content.createInput("button", "save", "Done") - input.setAttribute("onclick", 'javascript: donePopupData("' + dataType + '", "' + ids + '", "false");') - content.addInteraction(input) - break - - default: - break; - } - - showPopUpElement('popup-custom'); -} - -class XMLTVFile { - File:string - - getFiles(set:string):any { - var fileIDs:string[] = getObjKeys(SERVER["xepg"]["xmltvMap"]) - var values = new Array("-"); - var text = new Array("-"); - - for (let i = 0; i < fileIDs.length; i++) { - if (fileIDs[i] != "xTeVe Dummy") { - values.push(getValueFromProviderFile(fileIDs[i], "xmltv", "file.xteve")) - text.push(getValueFromProviderFile(fileIDs[i], "xmltv", "name")) - } else { - values.push(fileIDs[i]) - text.push(fileIDs[i]) - } - - } - - var select = document.createElement("SELECT") - for (let i = 0; i < text.length; i++) { - var option = document.createElement("OPTION") - option.setAttribute("value", values[i]) - option.innerText = text[i] - select.appendChild(option) - } - - if(set != "") { - (select as HTMLSelectElement).value = set - } - - return select - } - - getPrograms(file:string, set:string):any { - //var fileIDs:string[] = getObjKeys(SERVER["xepg"]["xmltvMap"]) - var values = getObjKeys(SERVER["xepg"]["xmltvMap"][file]); - var text = new Array() - var displayName:string - - for (let i = 0; i < values.length; i++) { - if (SERVER["xepg"]["xmltvMap"][file][values[i]].hasOwnProperty('display-name') == true) { - displayName = SERVER["xepg"]["xmltvMap"][file][values[i]]["display-name"]; - } else { - displayName = "-" - } - - text[i] = displayName + " (" + values[i] + ")"; - } - - text.unshift("-"); - values.unshift("-"); - - var select = document.createElement("SELECT") - for (let i = 0; i < text.length; i++) { - var option = document.createElement("OPTION") - option.setAttribute("value", values[i]) - option.innerText = text[i] - select.appendChild(option) - } - - if(set != "") { - (select as HTMLSelectElement).value = set - } - - if ((select as HTMLSelectElement).value != set) { - (select as HTMLSelectElement).value = "-" - } - - return select - } - - return -} - -function getValueFromProviderFile(file:string, fileType, key) { - - if (file == "xTeVe Dummy") { - return file - } - - var fileID:string - var indicator = file.charAt(0) - - switch (indicator) { - case "M": - fileType = "m3u" - fileID = file - break; - - case "H": - fileType = "hdhr" - fileID = file - break; - - case "X": - fileType = "xmltv" - fileID = file.substring(0, file.lastIndexOf('.')) - break; - - } - - if (SERVER["settings"]["files"][fileType].hasOwnProperty(fileID) == true) { - var data = SERVER["settings"]["files"][fileType][fileID]; - return data[key] - } - - return - -} - -function setXmltvChannel(id, element) { - - var xmltv:XMLTVFile = new XMLTVFile() - var xmlFile = element.value - - var tvgId:string = SERVER["xepg"]["epgMapping"][id]["tvg-id"] - var td = document.getElementById("popup-mapping").parentElement - td.innerHTML = "" - - var select = xmltv.getPrograms(element.value, tvgId) - select.setAttribute("name", "x-mapping") - select.setAttribute("id", "popup-mapping") - select.setAttribute("onchange", "javascript: this.className = 'changed'; checkXmltvChannel('" + id + "',this,'" + xmlFile + "');") - select.className = "changed" - sortSelect(select) - td.appendChild(select); - - checkXmltvChannel(id, select, xmlFile) -} - -function checkXmltvChannel(id:string, element:any, xmlFile) { - - var value = (element as HTMLSelectElement).value - var bool:boolean - var checkbox = document.getElementById('active') - var channel:any = SERVER["xepg"]["epgMapping"][id] - var updateLogo:boolean - - - if (value == "-") { - bool = false - } else { - bool = true - } - - (checkbox as HTMLInputElement).checked = bool - checkbox.className = "changed" - console.log(xmlFile); - - // Kanallogo aktualisieren - /* - updateLogo = (document.getElementById("update-icon") as HTMLInputElement).checked - console.log(updateLogo); - */ - - if(xmlFile != "xTeVe Dummy" && bool == true) { - - //(document.getElementById("update-icon") as HTMLInputElement).checked = true; - //(document.getElementById("update-icon") as HTMLInputElement).className = "changed"; - - console.log("ID", id) - changeChannelLogo(id) - - return - } - - if (xmlFile == "xTeVe Dummy") { - (document.getElementById("update-icon") as HTMLInputElement).checked = false; - (document.getElementById("update-icon") as HTMLInputElement).className = "changed"; - } - - return -} - -function changeChannelLogo(id:string) { - - var updateLogo:boolean - var channel:any = SERVER["xepg"]["epgMapping"][id] - - var f = (document.getElementById("popup-xmltv") as HTMLSelectElement); - var xmltvFile = f.options[f.selectedIndex].value; - - var m = (document.getElementById("popup-mapping") as HTMLSelectElement); - var xMapping = m.options[m.selectedIndex].value; - - var xmltvLogo = SERVER["xepg"]["xmltvMap"][xmltvFile][xMapping]["icon"] - updateLogo = (document.getElementById("update-icon") as HTMLInputElement).checked - - if (updateLogo == true && xmltvFile != "xTeVe Dummy") { - - if (SERVER["xepg"]["xmltvMap"][xmltvFile].hasOwnProperty(xMapping)) { - var logo = xmltvLogo - } else { - logo = channel["tvg-logo"] - } - - var logoInput = (document.getElementById("channel-icon") as HTMLInputElement); - logoInput.value = logo - if (BULK_EDIT == false) { - logoInput.className = "changed" - } - - } - -} - -function savePopupData(dataType:string, id:string, remove:Boolean, option:number) { - - if (dataType == "mapping") { - - var data = new Object() - console.log("Save mapping data") - - cmd = "saveEpgMapping" - data["epgMapping"] = SERVER["xepg"]["epgMapping"] - - console.log("SEND TO SERVER"); - - var server:Server = new Server(cmd) - server.request(data) - - delete UNDO["epgMapping"] - - return - } - - console.log("Save popup data") - var div = document.getElementById("popup-custom") - - var inputs = div.getElementsByTagName("TABLE")[0].getElementsByTagName("INPUT"); - var selects = div.getElementsByTagName("TABLE")[0].getElementsByTagName("SELECT"); - - var input = new Object(); - var confirmMsg: string - - for (let i = 0; i < selects.length; i++) { - - var name:string - name = (selects[i] as HTMLSelectElement).name - var value = (selects[i] as HTMLSelectElement).value - - switch (name) { - case "tuner": - input[name] = parseInt(value) - break; - - default: - input[name] = value - break; - } - - } - - for (let i = 0; i < inputs.length; i++) { - - switch ((inputs[i] as HTMLInputElement).type) { - - case "checkbox": - name = (inputs[i] as HTMLInputElement).name - input[name] = (inputs[i] as HTMLInputElement).checked - break - - case "text": - case "hidden": - case "password": - - name = (inputs[i] as HTMLInputElement).name - - switch (name) { - case "tuner": - input[name] = parseInt((inputs[i] as HTMLInputElement).value) - break; - - default: - input[name] = (inputs[i] as HTMLInputElement).value - break; - } - - break - - } - - } - - var data = new Object() - - var cmd:string - - if (remove == true) { - input["delete"] = true - } - - switch (dataType) { - case "users": - - confirmMsg = "Delete this user?" - if (id == "-") { - cmd = "saveNewUser" - data["userData"] = input - } else { - cmd = "saveUserData" - var d = new Object() - d[id] = input - data["userData"] = d - } - - break; - - case "m3u": - - confirmMsg = "Delete this playlist?" - switch (option) { - // Popup: Save - case 0: - cmd = "saveFilesM3U" - break - - // Popup: Update - case 1: - cmd = "updateFileM3U" - break - - } - - data["files"] = new Object - data["files"][dataType] = new Object - data["files"][dataType][id] = input - - break - - case "hdhr": - - confirmMsg = "Delete this HDHomeRun tuner?" - switch (option) { - // Popup: Save - case 0: - cmd = "saveFilesHDHR" - break - - // Popup: Update - case 1: - cmd = "updateFileHDHR" - break - - } - - data["files"] = new Object - data["files"][dataType] = new Object - data["files"][dataType][id] = input - - break - - case "xmltv": - - confirmMsg = "Delete this XMLTV file?" - switch (option) { - // Popup: Save - case 0: - cmd = "saveFilesXMLTV" - break - - // Popup: Update - case 1: - cmd = "updateFileXMLTV" - break - - } - - data["files"] = new Object - data["files"][dataType] = new Object - data["files"][dataType][id] = input - - break - - case "filter": - - confirmMsg = "Delete this filter?" - cmd = "saveFilter" - data["filter"] = new Object - data["filter"][id] = input - break - - default: - console.log(dataType, id); - return - break; - - } - - if (remove == true) { - - if (!confirm(confirmMsg)) { - showElement("popup", false) - return - } - - } - - console.log("SEND TO SERVER"); - - console.log(data); - - var server:Server = new Server(cmd) - server.request(data) - -} - -function donePopupData(dataType:string, idsStr:string) { - - var ids:string[] = idsStr.split(','); - var div = document.getElementById("popup-custom") - var inputs = div.getElementsByClassName("changed") - - ids.forEach(id => { - var input = new Object(); - input = SERVER["xepg"]["epgMapping"][id] - - console.log(input); - - for (let i = 0; i < inputs.length; i++) { - - var name:string - var value:any - - switch (inputs[i].tagName) { - - case "INPUT": - switch ((inputs[i] as HTMLInputElement).type) { - case "checkbox": - name = (inputs[i] as HTMLInputElement).name - value = (inputs[i] as HTMLInputElement).checked - input[name] = value - break - - case "text": - name = (inputs[i] as HTMLInputElement).name - value = (inputs[i] as HTMLInputElement).value - input[name] = value - break - - } - - break - - case "SELECT": - name = (inputs[i] as HTMLSelectElement).name - value = (inputs[i] as HTMLSelectElement).value - input[name] = value - break - - } - - switch (name) { - - - case "tvg-logo": - //(document.getElementById(id).childNodes[2].firstChild as HTMLElement).setAttribute("src", value) - break - - case "x-name": - (document.getElementById(id).childNodes[3].firstChild as HTMLElement).textContent = value - break - - case "x-category": - (document.getElementById(id).childNodes[3].firstChild as HTMLElement).className = value - break - - case "x-group-title": - (document.getElementById(id).childNodes[5].firstChild as HTMLElement).textContent = value - break - - case "x-xmltv-file": - if (value != "xTeVe Dummy" && value != "-") { - value = getValueFromProviderFile(value, "xmltv", "name") - } - - if (value == "-") { - input["x-active"] = false - } - - (document.getElementById(id).childNodes[6].firstChild as HTMLElement).textContent = value - break - - case "x-mapping": - if (value == "-") { - input["x-active"] = false - } - - (document.getElementById(id).childNodes[7].firstChild as HTMLElement).textContent = value - - break - - default: - - } - - createSearchObj() - searchInMapping() - - } - - if (input["x-active"] == false) { - document.getElementById(id).className = "notActiveEPG" - } else { - document.getElementById(id).className = "activeEPG" - } - - console.log(input["tvg-logo"]); - (document.getElementById(id).childNodes[2].firstChild as HTMLElement).setAttribute("src", input["tvg-logo"]) - - - }); - - showElement("popup", false); - - return -} - -function showPreview(element:boolean) { - - var div = document.getElementById("myStreamsBox") - switch (element) { - - case false: - div.className = "notVisible" - return - break; - } - - var streams:string[] = ["activeStreams", "inactiveStreams"] - - streams.forEach(preview => { - - var table = document.getElementById(preview) - table.innerHTML = "" - var obj:string[] = SERVER["data"]["StreamPreviewUI"][preview] - - obj.forEach(channel => { - - var tr = document.createElement("TR") - var tdKey = document.createElement("TD") - var tdVal = document.createElement("TD") - - tdKey.className = "tdKey" - tdVal.className = "tdVal" - - switch (preview) { - case "activeStreams": - tdKey.innerText = "Channel: (+)" - break; - - case "inactiveStreams": - tdKey.innerText = "Channel: (-)" - break; - } - - tdVal.innerText = channel - tr.appendChild(tdKey) - tr.appendChild(tdVal) - - table.appendChild(tr) - - }); - - }); - - showElement("loading", false) - div.className = "visible" - - return -} diff --git a/ts/network.ts b/ts/network.ts index 419c0ec..e7d256b 100644 --- a/ts/network.ts +++ b/ts/network.ts @@ -228,7 +228,7 @@ class Server { if (response["status"] == false) { setConnectionState("offline") - alert(response["err"]) + alertUser(response["err"]) if (response.hasOwnProperty("reload")) { location.reload() } @@ -266,7 +266,7 @@ class Server { } if (response.hasOwnProperty("alert")) { - alert(response["alert"]) + alertUser(response["alert"]) } if (response.hasOwnProperty("reload")) { @@ -295,7 +295,7 @@ class Server { setConnectionState(state) if (WS_AVAILABLE == false && !isLogUpdate && requestCmd != "getServerConfig") { - alert("No websocket connection to xTeVe could be established. Check your network configuration.") + alertUser("No websocket connection to xTeVe could be established. Check your network configuration.") } }) diff --git a/ts/popup.ts b/ts/popup.ts new file mode 100644 index 0000000..8a8a434 --- /dev/null +++ b/ts/popup.ts @@ -0,0 +1,1171 @@ +class PopupWindow { + DocumentID:string = "popup-custom" + InteractionID:string = "interaction" + doc = document.getElementById(this.DocumentID) + + createTitle(title:string):any { + var td = document.createElement("TD") + td.className = "left" + td.innerHTML = title + ":" + return td + } + + createContent(element):any { + var td = document.createElement("TD") + td.appendChild(element) + return td + } + + createInteraction():any { + var div = document.createElement("div") + div.setAttribute("id", "popup-interaction") + div.className = "interaction" + this.doc.appendChild(div) + } +} + +class PopupContent extends PopupWindow{ + + table = document.createElement("TABLE") + + createHeadline(headline):void { + this.doc.innerHTML = "" + var element = document.createElement("H3") + element.innerHTML = headline.toUpperCase() + this.doc.appendChild(element) + + // Tabelle erstellen + this.table = document.createElement("TABLE") + this.doc.appendChild(this.table) + } + + appendRow(title:string, element:any):void { + var tr = document.createElement("TR") + + // Bezeichnung + if (title.length != 0) { + tr.appendChild(this.createTitle(title)) + } + + + // Content + tr.appendChild(this.createContent(element)) + this.table.appendChild(tr) + } + + + createInput(type:string, name:string, value:string):any { + + var input = document.createElement("INPUT") + if (value == undefined) { + value = "" + } + + input.setAttribute("type", type) + input.setAttribute("name", name) + input.setAttribute("value", value) + return input + } + + createCheckbox(name:string):any { + var input = document.createElement("INPUT") + + input.setAttribute("type", "checkbox") + input.setAttribute("name", name) + return input + } + + createSelect(text:string[], values:string[], set:string, dbKey:string):any { + var select = document.createElement("SELECT") + select.setAttribute("name", dbKey) + for (let i = 0; i < text.length; i++) { + var option = document.createElement("OPTION") + option.setAttribute("value", values[i]) + option.innerText = text[i] + select.appendChild(option) + } + if(set != "") { + (select as HTMLSelectElement).value = set + } + + if (set == undefined) { + (select as HTMLSelectElement).value = values[0] + } + + return select + } + + selectOption(select:any, value:string):any { + //select.selectedOptions = value + var s:HTMLSelectElement = (select as HTMLSelectElement) + s.options[s.selectedIndex].value = value + return select + } + + // isHTML must only be true for static text written in the source; never for data. + description(value:string, isHTML:boolean = false):any { + var tr = document.createElement("TR") + var td = document.createElement("TD") + var span = document.createElement("PRE") + + if (isHTML) { + span.innerHTML = value + } else { + span.textContent = value + } + + tr.appendChild(td) + + tr.appendChild(this.createContent(span)) + + this.table.appendChild(tr) + } + + // Interaktion + addInteraction(element:any) { + var interaction = document.getElementById("popup-interaction") + interaction.appendChild(element) + } +} + +// Buffer choices a playlist can make; "default" means the global setting. +const PLAYLIST_BUFFER_OPTIONS = [ + { text: "Default (global setting)", value: "default" }, + { text: "No buffer (pass the URL through)", value: "-" }, + { text: "xTeVe", value: "xteve" }, + { text: "FFmpeg", value: "ffmpeg" }, + { text: "VLC", value: "vlc" } +] + +function createBufferSelect(content:PopupContent, data:object):HTMLSelectElement { + var text:string[] = PLAYLIST_BUFFER_OPTIONS.map(function(o) { return o.text }) + var values:string[] = PLAYLIST_BUFFER_OPTIONS.map(function(o) { return o.value }) + var current:string = data["buffer"] + if (values.indexOf(current) == -1) { + current = "default" + } + return content.createSelect(text, values, current, "buffer") +} + +// playlistBufferActive: is a buffer in use for this playlist (its own choice, +// or the global setting when it has none)? Decides whether the tuner count +// can be edited. +function playlistBufferActive(data:object):boolean { + var own:string = data["buffer"] + if (own != undefined && own != "default" && own != "") { + return own != "-" + } + return SERVER["settings"]["buffer"] != "-" +} + +function openPopUp(dataType, element?) { + + var data:object = new Object(); + var id:any + switch (element) { + case undefined: + + switch (dataType) { + case "group-title": + if (id == undefined) { + id = -1 + } + data = getLocalData("filter", id) + data["type"] = "group-title" + break; + + case "custom-filter": + if (id == undefined) { + id = -1 + } + data = getLocalData("filter", id) + data["type"] = "custom-filter" + break; + + case "regex-filter": + if (id == undefined) { + id = -1 + } + data = getLocalData("filter", id) + data["type"] = "regex-filter" + break; + + default: + data["id.provider"] = "-" + data["type"] = dataType + id = "-" + break; + } + + break + + default: + id = element.id + data = getLocalData(dataType, id) + break; + } + + var content:PopupContent = new PopupContent() + + switch (dataType) { + case "playlist": + content.createHeadline("Playlist type") + // Type + var text:string[] = ["M3U", "HDHomeRun"] + var values:string[] = ["m3u", "hdhr"] + var select = content.createSelect(text, values, "", "type") + select.setAttribute("id", "type") + content.appendRow("Type", select) + const typeSelect:HTMLSelectElement = select + + // Interaktion + content.createInteraction() + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Weiter + var input = content.createInput("button", "next", "Next") + input.addEventListener("click", function() { openPopUp(typeSelect.value) }) + input.setAttribute("id", 'next') + content.addInteraction(input) + break + + case "m3u": + content.createHeadline(dataType) + // Name + var dbKey:string = "name" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Playlist name") + content.appendRow("Name", input) + + // Beschreibung + var dbKey:string = "description" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Description") + content.appendRow("Description", input) + + // URL + var dbKey:string = "file.source" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "File path or URL of the M3U") + content.appendRow("M3U File", input) + + // Buffer (per playlist override of the global setting) + var bufferSelect = createBufferSelect(content, data) + content.appendRow("Buffer", bufferSelect) + + // Tuner + if (playlistBufferActive(data)) { + var text:string[] = new Array() + var values:string[] = new Array() + + for (var i = 1; i <= 100; i++) { + text.push(i.toString()) + values.push(i.toString()) + } + + var dbKey:string = "tuner" + var select = content.createSelect(text, values, data[dbKey], dbKey) + content.appendRow("Tuner / Streams", select) + } else { + var dbKey:string = "tuner" + if (data[dbKey] == undefined) { + data[dbKey] = 1 + } + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("readonly", "true") + input.className = "notAvailable" + content.appendRow("Tuner / Streams", input) + } + + content.description("Number of parallel connections that can be established to the provider.
Only available with activated buffer.
New settings will only be applied after quitting all streams.", true) + + // Interaktion + content.createInteraction() + // Löschen + if (data["id.provider"]!= "-") { + var input = content.createInput("button", "delete", "Delete") + input.className = "delete" + input.addEventListener("click", function() { savePopupData("m3u", String(id), true, 0) }) + content.addInteraction(input) + } else { + var input = content.createInput("button", "back", "Back") + input.addEventListener("click", function() { openPopUp("playlist") }) + content.addInteraction(input) + } + + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Aktualisieren + if (data["id.provider"]!= "-") { + var input = content.createInput("button", "update", "Update") + input.addEventListener("click", function() { savePopupData("m3u", String(id), false, 1) }) + content.addInteraction(input) + } + + // Speichern + var input = content.createInput("button", "save", "Save") + input.addEventListener("click", function() { savePopupData("m3u", String(id), false, 0) }) + content.addInteraction(input) + break + + case "hdhr": + content.createHeadline(dataType) + // Name + var dbKey:string = "name" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Playlist name") + content.appendRow("Name", input) + + // Beschreibung + var dbKey:string = "description" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Description") + content.appendRow("Description", input) + + // URL + var dbKey:string = "file.source" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "IP address and port (192.168.1.10:5004)") + content.appendRow("HDHomeRun IP", input) + + // Buffer (per playlist override of the global setting) + var bufferSelect = createBufferSelect(content, data) + content.appendRow("Buffer", bufferSelect) + + // Tuner + if (playlistBufferActive(data)) { + var text:string[] = new Array() + var values:string[] = new Array() + + for (var i = 1; i <= 100; i++) { + text.push(i.toString()) + values.push(i.toString()) + } + + var dbKey:string = "tuner" + var select = content.createSelect(text, values, data[dbKey], dbKey) + content.appendRow("Tuner / Streams", select) + } else { + var dbKey:string = "tuner" + if (data[dbKey] == undefined) { + data[dbKey] = 1 + } + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("readonly", "true") + input.className = "notAvailable" + content.appendRow("Tuner / Streams", input) + } + + content.description("Number of parallel connections that can be established to the provider.
Only available with activated buffer.
New settings will only be applied after quitting all streams.", true) + + // Interaktion + content.createInteraction() + // Löschen + if (data["id.provider"]!= "-") { + var input = content.createInput("button", "delete", "Delete") + input.addEventListener("click", function() { savePopupData("hdhr", String(id), true, 0) }) + input.className = "delete" + content.addInteraction(input) + } else { + var input = content.createInput("button", "back", "Back") + input.addEventListener("click", function() { openPopUp("playlist") }) + content.addInteraction(input) + } + + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Aktualisieren + if (data["id.provider"]!= "-") { + var input = content.createInput("button", "update", "Update") + input.addEventListener("click", function() { savePopupData("hdhr", String(id), false, 1) }) + content.addInteraction(input) + } + + // Speichern + var input = content.createInput("button", "save", "Save") + input.addEventListener("click", function() { savePopupData("hdhr", String(id), false, 0) }) + content.addInteraction(input) + break + + case "filter": + content.createHeadline(dataType) + + // Type + var dbKey:string = "type" + var text:string[] = ["M3U: " + "Group Title", "xTeVe: " + "Custom Filter", "xTeVe: " + "Regular Expression"] + var values:string[] = ["group-title", "custom-filter", "regex-filter"] + var select = content.createSelect(text, values, "group-title", dbKey) + select.setAttribute("id", id) + content.appendRow("Type", select) + const filterTypeSelect:HTMLSelectElement = select + + // Interaktion + content.createInteraction() + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Weiter + var input = content.createInput("button", "next", "Next") + input.addEventListener("click", function() { openPopUp(filterTypeSelect.value) }) + input.setAttribute("id", 'next') + content.addInteraction(input) + break + + case "custom-filter": + case "group-title": + case "regex-filter": + + switch (dataType) { + case "custom-filter": + content.createHeadline("Custom") + break; + + case "group-title": + content.createHeadline("Group") + break; + + case "regex-filter": + content.createHeadline("Regular Expression") + break; + } + + // Name + var dbKey:string = "name" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Filter name") + content.appendRow("Filter Name", input) + + // Beschreibung + var dbKey:string = "description" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Description") + content.appendRow("Description", input) + + // Typ + var dbKey:string = "type" + var input = content.createInput("hidden", dbKey, data[dbKey]) + content.appendRow("", input) + + var filterType = data[dbKey] + + switch (filterType) { + + case "custom-filter": + // Groß- Kleinschreibung beachten + var dbKey:string = "caseSensitive" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + content.appendRow("Case Sensitive", input) + + // Filterregel (Benutzerdefiniert) + var dbKey:string = "filter" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Sport {HD} !{ES,IT}") + content.appendRow("Filter Rule", input) + + break; + + case "regex-filter": + // Groß- Kleinschreibung beachten + var dbKey:string = "caseSensitive" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + content.appendRow("Case Sensitive", input) + + // Regulärer Ausdruck + var dbKey:string = "filter" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "^(BBC|ITV) \\d+$") + content.appendRow("Regular Expression", input) + content.description("Go regular expression, tried against the channel name, then the group title, then the raw M3U attributes. Case-insensitive unless Case Sensitive is checked. Invalid patterns are rejected on save.") + + break; + + case "group-title": + //alert(dbKey + " " + filterType) + // Filter basierend auf den Gruppen in der M3U + var dbKey:string = "filter" + var groupsM3U = getLocalData("m3uGroups", "") + var text:string[] = groupsM3U["text"] + var values:string[] = groupsM3U["value"] + + var select = content.createSelect(text, values, data[dbKey], dbKey) + markChangedOnChange(select) + content.appendRow("Group Title", select) + content.description("Select a M3U group. (Counter)
Changing the group title in the M3U invalidates the filter.", true) + + // Groß- Kleinschreibung beachten + var dbKey:string = "caseSensitive" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + content.appendRow("Case Sensitive", input) + + + var dbKey:string = "include" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "FHD,UHD") + + content.appendRow("Include", input) + content.description("Channel name must include.
(Comma separated) Comma means or", true) + + var dbKey:string = "exclude" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "ES,IT") + content.appendRow("Exclude", input) + content.description("Channel name must not contain.
(Comma separated) Comma means or", true) + + break + + default: + break; + } + + // Interaktion + content.createInteraction() + + // Löschen + var input = content.createInput("button", "delete", "Delete") + input.addEventListener("click", function() { savePopupData("filter", String(id), true, 0) }) + input.className = "delete" + content.addInteraction(input) + + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Speichern + var input = content.createInput("button", "save", "Save") + input.addEventListener("click", function() { savePopupData("filter", String(id), false, 0) }) + content.addInteraction(input) + + break + + case "xmltv": + content.createHeadline(dataType) + // Name + var dbKey:string = "name" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Guide name") + content.appendRow("Name", input) + + // Beschreibung + var dbKey:string = "description" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Description") + content.appendRow("Description", input) + + // URL + var dbKey:string = "file.source" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "File path or URL of the XMLTV") + content.appendRow("XMLTV File", input) + + // Interaktion + content.createInteraction() + // Löschen + if (data["id.provider"]!= "-") { + var input = content.createInput("button", "delete", "Delete") + input.addEventListener("click", function() { savePopupData("xmltv", String(id), true, 0) }) + input.className = "delete" + content.addInteraction(input) + } + + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Aktualisieren + if (data["id.provider"]!= "-") { + var input = content.createInput("button", "update", "Update") + input.addEventListener("click", function() { savePopupData("xmltv", String(id), false, 1) }) + content.addInteraction(input) + } + + // Speichern + var input = content.createInput("button", "save", "Save") + input.addEventListener("click", function() { savePopupData("xmltv", String(id), false, 0) }) + content.addInteraction(input) + break + + case "users": + content.createHeadline("Users") + // Benutzername + var dbKey:string = "username" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Username") + content.appendRow("Username", input) + + // Neues Passwort + var dbKey:string = "password" + var input = content.createInput("password", dbKey, "") + input.setAttribute("placeholder", "Password") + content.appendRow("Password", input) + + // Bestätigung + var dbKey:string = "confirm" + var input = content.createInput("password", dbKey, "") + input.setAttribute("placeholder", "Password confirm") + content.appendRow("Confirm", input) + + // Berechtigung WEB + var dbKey:string = "authentication.web" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + if (data["defaultUser"] == true) { + input.addEventListener("click", function(event) { event.preventDefault() }) + } + content.appendRow("Web Access", input) + + // Berechtigung PMS + var dbKey:string = "authentication.pms" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + content.appendRow("PMS Access", input) + + // Berechtigung M3U + var dbKey:string = "authentication.m3u" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + content.appendRow("M3U Access", input) + + // Berechtigung XML + var dbKey:string = "authentication.xml" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + content.appendRow("XML Access", input) + + // Berechtigung API + var dbKey:string = "authentication.api" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + content.appendRow("API Access", input) + + // Interaktion + content.createInteraction() + + // Löschen + if (data["defaultUser"]!= true && id != "-") { + var input = content.createInput("button", "delete", "Delete") + input.className = "delete" + input.addEventListener("click", function() { savePopupData(dataType, String(id), true, 0) }) + content.addInteraction(input) + } + + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Speichern + var input = content.createInput("button", "save", "Save") + input.addEventListener("click", function() { savePopupData(dataType, String(id), false, 0) }) + content.addInteraction(input) + + break + + case "mapping": + content.createHeadline("Mapping") + // Aktiv + var dbKey:string = "x-active" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + input.id = "active" + //markChangedOnChange(input) + input.addEventListener("change", function() { toggleChannelStatus(id) }) + content.appendRow("Active", input) + + // Kanalname + var dbKey:string = "x-name" + var input = content.createInput("text", dbKey, data[dbKey]) + markChangedOnChange(input) + if (BULK_EDIT == true) { + input.style.border = "solid 1px red" + input.setAttribute("readonly", "true") + } + content.appendRow("Channel Name", input) + + content.description(data["name"]) + + // Beschreibung + var dbKey:string = "x-description" + var input = content.createInput("text", dbKey, data[dbKey]) + input.setAttribute("placeholder", "Used by the Dummy as an XML description") + markChangedOnChange(input) + content.appendRow("Channel Description", input) + + // Aktualisierung des Kanalnamens + if (data.hasOwnProperty("_uuid.key")) { + if (data["_uuid.key"] != "") { + var dbKey:string = "x-update-channel-name" + var input = content.createCheckbox(dbKey) + markChangedOnChange(input) + input.checked = data[dbKey] + content.appendRow("Update Channel Name", input) + } + } + + // Logo URL (Kanal) + var dbKey:string = "tvg-logo" + var input = content.createInput("text", dbKey, data[dbKey]) + markChangedOnChange(input) + input.setAttribute("id", "channel-icon") + content.appendRow("Logo URL", input) + + // Aktualisierung des Kanallogos + var dbKey:string = "x-update-channel-icon" + var input = content.createCheckbox(dbKey) + input.checked = data[dbKey] + input.setAttribute("id", "update-icon") + markChangedOnChange(input, function() { changeChannelLogo(id) }) + content.appendRow("Update Channel Logo", input) + + // Erweitern der EPG Kategorie + var dbKey:string = "x-category" + var text:string[] = ["-", "Kids (Emby only)", "News", "Movie", "Series", "Sports"] + var values:string[] = ["", "Kids", "News", "Movie", "Series", "Sports"] + var select = content.createSelect(text, values, data[dbKey], dbKey) + markChangedOnChange(select) + content.appendRow("EPG Category", select) + + // M3U Gruppentitel + var dbKey:string = "x-group-title" + var input = content.createInput("text", dbKey, data[dbKey]) + markChangedOnChange(input) + content.appendRow("Group Title (xteve.m3u)", input) + + if (data["group-title"] != undefined) { + content.description(data["group-title"]) + } + + // XMLTV Datei + var dbKey:string = "x-xmltv-file" + var xmlFile = data[dbKey] + var xmltv:XMLTVFile = new XMLTVFile() + var select = xmltv.getFiles(data[dbKey]) + select.setAttribute("name", dbKey) + select.setAttribute("id", "popup-xmltv") + markChangedOnChange(select, function(element) { setXmltvChannel(id, element) }) + content.appendRow("XMLTV File", select) + var file = data[dbKey] + + // XMLTV Mapping + var dbKey:string = "x-mapping" + var xmltv:XMLTVFile = new XMLTVFile() + var select = xmltv.getPrograms(file, data[dbKey]) + select.setAttribute("name", dbKey) + select.setAttribute("id", "popup-mapping") + markChangedOnChange(select, function(element) { checkXmltvChannel(id, element, xmlFile) }) + + sortSelect(select) + content.appendRow("XMLTV Channel", select) + + // Interaktion + content.createInteraction() + + // Logo hochladen + var input = content.createInput("button", "cancel", "Upload Logo") + input.addEventListener("click", function() { uploadLogo() }) + content.addInteraction(input) + + // Abbrechen + var input = content.createInput("button", "cancel", "Cancel") + input.addEventListener("click", function() { showElement("popup", false) }) + content.addInteraction(input) + + // Fertig + var ids:string[] = new Array() + ids = getAllSelectedChannels() + if (ids.length == 0) { + ids.push(id) + } + + var input = content.createInput("button", "save", "Done") + input.addEventListener("click", function() { donePopupData(dataType, ids.join(",")) }) + content.addInteraction(input) + break + + default: + break; + } + + showPopUpElement('popup-custom'); +} + +function savePopupData(dataType:string, id:string, remove:Boolean, option:number) { + + if (dataType == "mapping") { + + var data = new Object() + console.log("Save mapping data") + + cmd = "saveEpgMapping" + data["epgMapping"] = SERVER["xepg"]["epgMapping"] + + console.log("SEND TO SERVER"); + + var server:Server = new Server(cmd) + server.request(data) + + delete UNDO["epgMapping"] + + return + } + + console.log("Save popup data") + var div = document.getElementById("popup-custom") + + var inputs = div.getElementsByTagName("TABLE")[0].getElementsByTagName("INPUT"); + var selects = div.getElementsByTagName("TABLE")[0].getElementsByTagName("SELECT"); + + var input = new Object(); + var confirmMsg: string + + for (let i = 0; i < selects.length; i++) { + + var name:string + name = (selects[i] as HTMLSelectElement).name + var value = (selects[i] as HTMLSelectElement).value + + switch (name) { + case "tuner": + input[name] = parseInt(value) + break; + + default: + input[name] = value + break; + } + + } + + for (let i = 0; i < inputs.length; i++) { + + switch ((inputs[i] as HTMLInputElement).type) { + + case "checkbox": + name = (inputs[i] as HTMLInputElement).name + input[name] = (inputs[i] as HTMLInputElement).checked + break + + case "text": + case "hidden": + case "password": + + name = (inputs[i] as HTMLInputElement).name + + switch (name) { + case "tuner": + input[name] = parseInt((inputs[i] as HTMLInputElement).value) + break; + + default: + input[name] = (inputs[i] as HTMLInputElement).value + break; + } + + break + + } + + } + + var data = new Object() + + var cmd:string + + if (remove == true) { + input["delete"] = true + } + + switch (dataType) { + case "users": + + confirmMsg = "Delete this user?" + if (id == "-") { + cmd = "saveNewUser" + data["userData"] = input + } else { + cmd = "saveUserData" + var d = new Object() + d[id] = input + data["userData"] = d + } + + break; + + case "m3u": + + confirmMsg = "Delete this playlist?" + switch (option) { + // Popup: Save + case 0: + cmd = "saveFilesM3U" + break + + // Popup: Update + case 1: + cmd = "updateFileM3U" + break + + } + + data["files"] = new Object + data["files"][dataType] = new Object + data["files"][dataType][id] = input + + break + + case "hdhr": + + confirmMsg = "Delete this HDHomeRun tuner?" + switch (option) { + // Popup: Save + case 0: + cmd = "saveFilesHDHR" + break + + // Popup: Update + case 1: + cmd = "updateFileHDHR" + break + + } + + data["files"] = new Object + data["files"][dataType] = new Object + data["files"][dataType][id] = input + + break + + case "xmltv": + + confirmMsg = "Delete this XMLTV file?" + switch (option) { + // Popup: Save + case 0: + cmd = "saveFilesXMLTV" + break + + // Popup: Update + case 1: + cmd = "updateFileXMLTV" + break + + } + + data["files"] = new Object + data["files"][dataType] = new Object + data["files"][dataType][id] = input + + break + + case "filter": + + confirmMsg = "Delete this filter?" + cmd = "saveFilter" + data["filter"] = new Object + data["filter"][id] = input + break + + default: + console.log(dataType, id); + return + break; + + } + + if (remove == true) { + + if (!confirm(confirmMsg)) { + showElement("popup", false) + return + } + + } + + console.log("SEND TO SERVER"); + + console.log(data); + + var server:Server = new Server(cmd) + server.request(data) + +} + +function donePopupData(dataType:string, idsStr:string) { + + var ids:string[] = idsStr.split(','); + var div = document.getElementById("popup-custom") + var inputs = div.getElementsByClassName("changed") + + ids.forEach(id => { + var input = new Object(); + input = SERVER["xepg"]["epgMapping"][id] + + console.log(input); + + for (let i = 0; i < inputs.length; i++) { + + var name:string + var value:any + + switch (inputs[i].tagName) { + + case "INPUT": + switch ((inputs[i] as HTMLInputElement).type) { + case "checkbox": + name = (inputs[i] as HTMLInputElement).name + value = (inputs[i] as HTMLInputElement).checked + input[name] = value + break + + case "text": + name = (inputs[i] as HTMLInputElement).name + value = (inputs[i] as HTMLInputElement).value + input[name] = value + break + + } + + break + + case "SELECT": + name = (inputs[i] as HTMLSelectElement).name + value = (inputs[i] as HTMLSelectElement).value + input[name] = value + break + + } + + switch (name) { + + + case "tvg-logo": + //(document.getElementById(id).childNodes[2].firstChild as HTMLElement).setAttribute("src", value) + break + + case "x-name": + (document.getElementById(id).childNodes[3].firstChild as HTMLElement).textContent = value + break + + case "x-category": + (document.getElementById(id).childNodes[3].firstChild as HTMLElement).className = value + break + + case "x-group-title": + (document.getElementById(id).childNodes[5].firstChild as HTMLElement).textContent = value + break + + case "x-xmltv-file": + if (value != "xTeVe Dummy" && value != "-") { + value = getValueFromProviderFile(value, "xmltv", "name") + } + + if (value == "-") { + input["x-active"] = false + } + + (document.getElementById(id).childNodes[6].firstChild as HTMLElement).textContent = value + break + + case "x-mapping": + if (value == "-") { + input["x-active"] = false + } + + (document.getElementById(id).childNodes[7].firstChild as HTMLElement).textContent = value + + break + + default: + + } + + createSearchObj() + searchInMapping() + + } + + if (input["x-active"] == false) { + document.getElementById(id).className = "notActiveEPG" + } else { + document.getElementById(id).className = "activeEPG" + } + + console.log(input["tvg-logo"]); + (document.getElementById(id).childNodes[2].firstChild as HTMLElement).setAttribute("src", input["tvg-logo"]) + + + }); + + showElement("popup", false); + + return +} + +function showPreview(element:boolean) { + + var div = document.getElementById("myStreamsBox") + switch (element) { + + case false: + div.className = "notVisible" + return + break; + } + + var streams:string[] = ["activeStreams", "inactiveStreams"] + + streams.forEach(preview => { + + var table = document.getElementById(preview) + table.innerHTML = "" + var obj:string[] = SERVER["data"]["StreamPreviewUI"][preview] + + obj.forEach(channel => { + + var tr = document.createElement("TR") + var tdKey = document.createElement("TD") + var tdVal = document.createElement("TD") + + tdKey.className = "tdKey" + tdVal.className = "tdVal" + + switch (preview) { + case "activeStreams": + tdKey.innerText = "Channel: (+)" + break; + + case "inactiveStreams": + tdKey.innerText = "Channel: (-)" + break; + } + + tdVal.innerText = channel + tr.appendChild(tdKey) + tr.appendChild(tdVal) + + table.appendChild(tr) + + }); + + }); + + showElement("loading", false) + div.className = "visible" + + return +} diff --git a/ts/settings.ts b/ts/settings.ts index 1ec4838..b1818f8 100644 --- a/ts/settings.ts +++ b/ts/settings.ts @@ -1,3 +1,119 @@ +// One entry per settings key. The categories in base.ts decide which keys +// are shown and in which order; this table decides how each one renders. +interface SettingsFieldOption { + text:string + value:string +} + +interface SettingsField { + key:string + title:string + type:"text" | "checkbox" | "number" | "select" | "password" + placeholder?:string + autocomplete?:string + options?:SettingsFieldOption[] + // Static text written in the source; may contain HTML. + description:string +} + +function settingsOptions(text:string[], values:string[]):SettingsFieldOption[] { + var options:SettingsFieldOption[] = [] + for (var i = 0; i < text.length; i++) { + options.push({text: text[i], value: values[i]}) + } + return options +} + +function settingsNumberOptions(from:number, to:number):SettingsFieldOption[] { + var options:SettingsFieldOption[] = [] + for (var i = from; i <= to; i++) { + options.push({text: i.toString(), value: i.toString()}) + } + return options +} + +const SETTINGS_FIELDS:SettingsField[] = [ + + // Text inputs + {key: "update", title: "Schedule for updating (Playlist, XMLTV, Backup)", type: "text", placeholder: "0000,1000,2000", + description: "Time in 24 hour format (0800 = 8:00 am). More times can be entered comma separated. Leave this field empty if no updates are to be carried out."}, + {key: "backup.path", title: "Location for automatic backups", type: "text", placeholder: "/mnt/data/backup/xteve/", + description: "Before any update of the provider data by the schedule, xTeVe creates a backup. The path for the automatic backups can be changed. xTeVe requires write permission for this folder."}, + {key: "temp.path", title: "Location for the temporary files", type: "text", placeholder: "/tmp/xteve/", + description: "Location for the buffer files."}, + {key: "user.agent", title: "User Agent", type: "text", placeholder: "xTeVe", + description: "User Agent for HTTP requests. For every HTTP connection, this value is used for the user agent. Should only be changed if xTeVe is blocked."}, + {key: "plex.url", title: "Plex Server URL", type: "text", placeholder: "http://plex-host:32400", + description: "Base URL of your Plex server. Example: http://192.168.1.10:32400"}, + // The server sends the mask "********" and treats it coming back as "unchanged". + {key: "plex.token", title: "Plex API Token", type: "password", placeholder: "Plex X-Plex-Token", autocomplete: "off", + description: "Plex token used for authenticated API calls to refresh your DVR guide."}, + {key: "buffer.timeout", title: "Timeout for new client connections", type: "text", placeholder: "100", + description: "The xTeVe buffer waits until new client connections are established. Helpful for fast channel switching. Value in milliseconds."}, + {key: "ffmpeg.path", title: "FFmpeg Binary Path", type: "text", placeholder: "/path/to/ffmpeg", + description: "Path to FFmpeg binary."}, + {key: "ffmpeg.options", title: "FFmpeg Options", type: "text", placeholder: "Leave blank to set default settings", + description: "FFmpeg options.
Only change if you know what you are doing.
Leave blank to set default settings."}, + {key: "vlc.path", title: "VLC / CVLC Binary Path", type: "text", placeholder: "/path/to/cvlc", + description: "Path to VLC / CVLC binary."}, + {key: "vlc.options", title: "VLC / CVLC Options", type: "text", placeholder: "Leave blank to set default settings", + description: "VLC / CVLC options.
Only change if you know what you are doing.
Leave blank to set default settings."}, + {key: "udpxy", title: "UDPxy address", type: "text", placeholder: "host:port", + description: "The address of your UDPxy server. If set, and the channel URLs in the m3u is multicast, xTeVe will rewrite it so that it is accessed via the UDPxy service."}, + + // Checkboxes + {key: "authentication.web", title: "WEB Authentication", type: "checkbox", + description: "Access to the web interface only possible with credentials."}, + {key: "authentication.pms", title: "PMS Authentication", type: "checkbox", + description: "Plex requests are only possible with authentication.
Warning!!! After activating this function xTeVe must be delete in the PMS DVR settings and set up again."}, + {key: "authentication.m3u", title: "M3U Authentication", type: "checkbox", + description: "Downloading the xteve.m3u file via an HTTP request is only possible with authentication."}, + {key: "authentication.xml", title: "XML Authentication", type: "checkbox", + description: "Downloading the xteve.xml file via an HTTP request is only possible with authentication"}, + // Only rendered while web authentication is on (see SettingsCategoryItem.createCategory). + {key: "authentication.api", title: "API Authentication", type: "checkbox", + description: "Access to the API interface is only possible with authentication."}, + {key: "files.update", title: "Updates all files at startup", type: "checkbox", + description: "Updates all playlists, tuner and XMLTV files at startup."}, + {key: "cache.images", title: "Image Caching", type: "checkbox", + description: "All images from the XMLTV file are cached, allowing faster rendering of the grid in the client.
Downloading the images may take a while and will be done in the background."}, + {key: "xepg.replace.missing.images", title: "Replace missing program images", type: "checkbox", + description: "If the poster in the XMLTV program is missing, the channel logo will be used."}, + {key: "api", title: "API Interface", type: "checkbox", + description: "Via API interface it is possible to send commands to xTeVe. API documentation is here"}, + {key: "use_plexAPI", title: "Use Plex API Refresh", type: "checkbox", + description: "When enabled, xTeVe calls Plex directly to refresh DVR guide data after lineup or XEPG updates."}, + + // Selects + {key: "xepg.missing.epg.mode", title: "Missing EPG Handling", type: "select", + options: settingsOptions(["Strict (deactivate channels)", "Relaxed (keep active / dummy guide)"], ["strict", "relaxed"]), + description: "Strict: channels are deactivated when XMLTV mappings disappear.
Relaxed: channels stay active with last-known mappings and fall back to a dummy guide whenever guide data cannot be resolved."}, + {key: "tuner", title: "Number of Tuners", type: "select", + options: settingsNumberOptions(1, 100), + description: "Number of parallel connections that can be established to the provider.
Available for: Plex, Emby (HDHR), M3U (with active buffer).
After a change, xTeVe must be delete in the Plex / Emby DVR settings and set up again."}, + {key: "epgSource", title: "EPG Source", type: "select", + options: settingsOptions(["PMS", "XEPG"], ["PMS", "XEPG"]), + description: "PMS:
- Use EPG data from Plex or Emby

XEPG:
- Use of one or more XMLTV files
- Channel management
- M3U / XMLTV export (HTTP link for IPTV apps)"}, + {key: "backup.keep", title: "Number of backups to keep", type: "select", + options: settingsOptions(["5", "10", "20", "30", "40", "50"], ["5", "10", "20", "30", "40", "50"]), + description: "Number of backups to keep. Older backups are automatically deleted."}, + {key: "buffer.size.kb", title: "Buffer Size", type: "select", + options: settingsOptions(["0.5 MB", "1 MB", "2 MB", "3 MB", "4 MB", "5 MB", "6 MB", "7 MB", "8 MB"], ["512", "1024", "2048", "3072", "4096", "5120", "6144", "7168", "8192"]), + description: "Buffer size in MB.
M3U8: If the TS segment smaller then the buffer size, the file size of the segment is used."}, + {key: "buffer", title: "Stream Buffer", type: "select", + options: settingsOptions(["No Buffer (Client connects to the streaming server)", "xTeVe: (xTeVe connects to the streaming server)", "FFmpeg: (FFmpeg connects to the streaming server)", "VLC: (VLC connects to the streaming server)"], ["-", "xteve", "ffmpeg", "vlc"]), + description: "Functions of the buffer:
- The stream is passed from xTeVe, FFmpeg or VLC to Plex, Emby or M3U Player
- Small jerking of the streams can be compensated
- HLS / M3U8 support
- RTP / RTPS support (only FFmpeg or VLC)
- Re-streaming
- Separate tuner limit for each playlist"}, +] + +function getSettingsField(settingsKey:string):SettingsField { + for (var i = 0; i < SETTINGS_FIELDS.length; i++) { + if (SETTINGS_FIELDS[i].key == settingsKey) { + return SETTINGS_FIELDS[i] + } + } + return undefined +} + class SettingsCategory { DocumentID:string = "content_settings" createCategoryHeadline(value:string):any { @@ -11,558 +127,70 @@ class SettingsCategory { return element } - createSettings(settingsKey:string):any { - var setting = document.createElement("TR") + // createControl builds the input for one field, filled with the current value. + createControl(field:SettingsField, data:any):HTMLElement { var content:PopupContent = new PopupContent() - var data = SERVER["settings"][settingsKey] + var control:HTMLElement - switch (settingsKey) { + switch (field.type) { - // Texteingaben - case "update": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Schedule for updating (Playlist, XMLTV, Backup)" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "update", data.toString()) - input.setAttribute("placeholder", "0000,1000,2000") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) + case "checkbox": + control = content.createCheckbox(field.key); + (control as HTMLInputElement).checked = data break - case "backup.path": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Location for automatic backups" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "backup.path", data) - input.setAttribute("placeholder", "/mnt/data/backup/xteve/") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "temp.path": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Location for the temporary files" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "temp.path", data) - input.setAttribute("placeholder", "/tmp/xteve/") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "user.agent": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "User Agent" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "user.agent", data) - input.setAttribute("placeholder", "xTeVe") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "plex.url": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Plex Server URL" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "plex.url", data) - input.setAttribute("placeholder", "http://plex-host:32400") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "plex.token": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Plex API Token" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("password", "plex.token", data) - input.setAttribute("placeholder", "Plex X-Plex-Token") - input.setAttribute("autocomplete", "off") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "buffer.timeout": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Timeout for new client connections" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "buffer.timeout", data) - input.setAttribute("placeholder", "100") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "ffmpeg.path": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "FFmpeg Binary Path" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "ffmpeg.path", data) - input.setAttribute("placeholder", "/path/to/ffmpeg") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "ffmpeg.options": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "FFmpeg Options" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "ffmpeg.options", data) - input.setAttribute("placeholder", "Leave blank to set default settings") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "vlc.path": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "VLC / CVLC Binary Path" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "vlc.path", data) - input.setAttribute("placeholder", "/path/to/cvlc") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "vlc.options": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "VLC / CVLC Options" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "vlc.options", data) - input.setAttribute("placeholder", "Leave blank to set default settings") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - // Checkboxen - case "authentication.web": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "WEB Authentication" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "authentication.pms": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "PMS Authentication" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "authentication.m3u": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "M3U Authentication" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "authentication.xml": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "XML Authentication" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "authentication.api": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "API Authentication" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "files.update": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Updates all files at startup" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "cache.images": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Image Caching" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "xepg.replace.missing.images": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Replace missing program images" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "api": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "API Interface" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "use_plexAPI": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Use Plex API Refresh" + ":" - - var tdRight = document.createElement("TD") - var input = content.createCheckbox(settingsKey) - input.checked = data - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - // Select - case "xepg.missing.epg.mode": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Missing EPG Handling" + ":" - - var tdRight = document.createElement("TD") - var text:any[] = ["Strict (deactivate channels)", "Relaxed (keep active / dummy guide)"] - var values:any[] = ["strict", "relaxed"] - - var select = content.createSelect(text, values, data, settingsKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(select) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "tuner": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Number of Tuners" + ":" - - var tdRight = document.createElement("TD") - var text = new Array() - var values = new Array() - - for (var i = 1; i <= 100; i++) { - text.push(i) - values.push(i) - } - - var select = content.createSelect(text, values, data, settingsKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(select) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "epgSource": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "EPG Source" + ":" - - var tdRight = document.createElement("TD") - var text:any[] = ["PMS", "XEPG"] - var values:any[] = ["PMS", "XEPG"] - - var select = content.createSelect(text, values, data, settingsKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(select) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "backup.keep": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Number of backups to keep" + ":" - - var tdRight = document.createElement("TD") - var text:any[] = ["5", "10", "20", "30", "40", "50"] - var values:any[] = ["5", "10", "20", "30", "40", "50"] - - var select = content.createSelect(text, values, data, settingsKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(select) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "buffer.size.kb": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Buffer Size" + ":" - - var tdRight = document.createElement("TD") - var text:any[] = ["0.5 MB", "1 MB", "2 MB", "3 MB", "4 MB", "5 MB", "6 MB", "7 MB", "8 MB"] - var values:any[] = ["512", "1024", "2048", "3072", "4096", "5120", "6144", "7168", "8192"] - - var select = content.createSelect(text, values, data, settingsKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(select) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "buffer": - var tdLeft = document.createElement("TD") - tdLeft.innerHTML = "Stream Buffer" + ":" - - var tdRight = document.createElement("TD") - var text:any[] = ["No Buffer (Client connects to the streaming server)", "xTeVe: (xTeVe connects to the streaming server)", "FFmpeg: (FFmpeg connects to the streaming server)", "VLC: (VLC connects to the streaming server)"] - var values:any[] = ["-", "xteve", "ffmpeg", "vlc"] - - var select = content.createSelect(text, values, data, settingsKey) - select.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(select) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - case "udpxy": - - var tdLeft = document.createElement("TD"); - tdLeft.innerHTML = "UDPxy address" + ":" - - var tdRight = document.createElement("TD") - var input = content.createInput("text", "udpxy", data) - input.setAttribute("placeholder", "host:port") - input.setAttribute("onchange", "javascript: this.className = 'changed'") - tdRight.appendChild(input) - - setting.appendChild(tdLeft) - setting.appendChild(tdRight) - break - - } - - return setting - - } - - - createDescription(settingsKey:string):any { - - var description = document.createElement("TR") - var text:string - switch (settingsKey) { - - case "authentication.web": - text = "Access to the web interface only possible with credentials." - break - - case "authentication.m3u": - text = "Downloading the xteve.m3u file via an HTTP request is only possible with authentication." - break - - case "authentication.pms": - text = "Plex requests are only possible with authentication.
Warning!!! After activating this function xTeVe must be delete in the PMS DVR settings and set up again." - break - - case "authentication.xml": - text = "Downloading the xteve.xml file via an HTTP request is only possible with authentication" - break - - case "authentication.api": - if (SERVER["settings"]["authentication.web"] == true) { - text = "Access to the API interface is only possible with authentication." - } - break - - case "backup.keep": - text = "Number of backups to keep. Older backups are automatically deleted." - break - - case "backup.path": - text = "Before any update of the provider data by the schedule, xTeVe creates a backup. The path for the automatic backups can be changed. xTeVe requires write permission for this folder." - break - - case "temp.path": - text = "Location for the buffer files." - break - - case "buffer": - text = "Functions of the buffer:
- The stream is passed from xTeVe, FFmpeg or VLC to Plex, Emby or M3U Player
- Small jerking of the streams can be compensated
- HLS / M3U8 support
- RTP / RTPS support (only FFmpeg or VLC)
- Re-streaming
- Separate tuner limit for each playlist" - break - - case "buffer.size.kb": - text = "Buffer size in MB.
M3U8: If the TS segment smaller then the buffer size, the file size of the segment is used." - break - - case "buffer.timeout": - text = "The xTeVe buffer waits until new client connections are established. Helpful for fast channel switching. Value in milliseconds." - break - - case "user.agent": - text = "User Agent for HTTP requests. For every HTTP connection, this value is used for the user agent. Should only be changed if xTeVe is blocked." - break - - case "plex.url": - text = "Base URL of your Plex server. Example: http://192.168.1.10:32400" - break - - case "plex.token": - text = "Plex token used for authenticated API calls to refresh your DVR guide." - break - - case "ffmpeg.path": - text = "Path to FFmpeg binary." - break - - case "ffmpeg.options": - text = "FFmpeg options.
Only change if you know what you are doing.
Leave blank to set default settings." - break - - case "vlc.path": - text = "Path to VLC / CVLC binary." - break - - case "vlc.options": - text = "VLC / CVLC options.
Only change if you know what you are doing.
Leave blank to set default settings." - break - - case "epgSource": - text = "PMS:
- Use EPG data from Plex or Emby

XEPG:
- Use of one or more XMLTV files
- Channel management
- M3U / XMLTV export (HTTP link for IPTV apps)" - break - - case "tuner": - text = "Number of parallel connections that can be established to the provider.
Available for: Plex, Emby (HDHR), M3U (with active buffer).
After a change, xTeVe must be delete in the Plex / Emby DVR settings and set up again." - break - - case "update": - text = "Time in 24 hour format (0800 = 8:00 am). More times can be entered comma separated. Leave this field empty if no updates are to be carried out." - break - - case "api": - text = "Via API interface it is possible to send commands to xTeVe. API documentation is here" - break - - case "use_plexAPI": - text = "When enabled, xTeVe calls Plex directly to refresh DVR guide data after lineup or XEPG updates." - break - - case "files.update": - text = "Updates all playlists, tuner and XMLTV files at startup." - break - - case "cache.images": - text = "All images from the XMLTV file are cached, allowing faster rendering of the grid in the client.
Downloading the images may take a while and will be done in the background." - break - - case "xepg.replace.missing.images": - text = "If the poster in the XMLTV program is missing, the channel logo will be used." - break - - case "xepg.missing.epg.mode": - text = "Strict: channels are deactivated when XMLTV mappings disappear.
Relaxed: channels stay active with last-known mappings and fall back to a dummy guide whenever guide data cannot be resolved." - break - - case "udpxy": - text = "The address of your UDPxy server. If set, and the channel URLs in the m3u is multicast, xTeVe will rewrite it so that it is accessed via the UDPxy service." + case "select": + var text:string[] = field.options.map(function(option) { return option.text }) + var values:string[] = field.options.map(function(option) { return option.value }) + control = content.createSelect(text, values, data, field.key) break default: - text = "" + // "text", "password", "number": lists (the update schedule) render comma separated. + var value:any = data + if (Array.isArray(data)) { + value = data.join(",") + } + control = content.createInput(field.type, field.key, value) + if (field.placeholder != undefined) { + control.setAttribute("placeholder", field.placeholder) + } + if (field.autocomplete != undefined) { + control.setAttribute("autocomplete", field.autocomplete) + } break + } + markChangedOnChange(control) + return control + } + + createSettings(settingsKey:string):any { + var setting = document.createElement("TR") + var field:SettingsField = getSettingsField(settingsKey) + if (field == undefined) { + return setting + } + + var data = SERVER["settings"][settingsKey] + + var tdLeft = document.createElement("TD") + tdLeft.textContent = field.title + ":" + + var tdRight = document.createElement("TD") + tdRight.appendChild(this.createControl(field, data)) + + setting.appendChild(tdLeft) + setting.appendChild(tdRight) + return setting + } + + createDescription(settingsKey:string):any { + var description = document.createElement("TR") + var field:SettingsField = getSettingsField(settingsKey) + var text:string = "" + if (field != undefined) { + text = field.description } var tdLeft = document.createElement("TD") @@ -577,7 +205,6 @@ class SettingsCategory { description.appendChild(tdRight) return description - } } @@ -669,6 +296,7 @@ function saveSettings() { break case "text": + case "password": name = (settings[i] as HTMLInputElement).name value = (settings[i] as HTMLInputElement).value diff --git a/ts/tsconfig.json b/ts/tsconfig.json index 41433d0..004d754 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -11,12 +11,15 @@ "removeComments": false }, "files": [ - "network_ts.ts", - "menu_ts.ts", - "settings_ts.ts", - "logs_ts.ts", - "base_ts.ts", - "configuration_ts.ts", - "authentication_ts.ts" + "network.ts", + "menu.ts", + "content.ts", + "popup.ts", + "xmltv.ts", + "settings.ts", + "logs.ts", + "base.ts", + "configuration.ts", + "authentication.ts" ] } diff --git a/ts/xmltv.ts b/ts/xmltv.ts new file mode 100644 index 0000000..79adadc --- /dev/null +++ b/ts/xmltv.ts @@ -0,0 +1,206 @@ +class XMLTVFile { + File:string + + getFiles(set:string):any { + var fileIDs:string[] = getObjKeys(SERVER["xepg"]["xmltvMap"]) + var values = new Array("-"); + var text = new Array("-"); + + for (let i = 0; i < fileIDs.length; i++) { + if (fileIDs[i] != "xTeVe Dummy") { + values.push(getValueFromProviderFile(fileIDs[i], "xmltv", "file.xteve")) + text.push(getValueFromProviderFile(fileIDs[i], "xmltv", "name")) + } else { + values.push(fileIDs[i]) + text.push(fileIDs[i]) + } + + } + + var select = document.createElement("SELECT") + for (let i = 0; i < text.length; i++) { + var option = document.createElement("OPTION") + option.setAttribute("value", values[i]) + option.innerText = text[i] + select.appendChild(option) + } + + if(set != "") { + (select as HTMLSelectElement).value = set + } + + return select + } + + getPrograms(file:string, set:string):any { + //var fileIDs:string[] = getObjKeys(SERVER["xepg"]["xmltvMap"]) + var values = getObjKeys(SERVER["xepg"]["xmltvMap"][file]); + var text = new Array() + var displayName:string + + for (let i = 0; i < values.length; i++) { + if (SERVER["xepg"]["xmltvMap"][file][values[i]].hasOwnProperty('display-name') == true) { + displayName = SERVER["xepg"]["xmltvMap"][file][values[i]]["display-name"]; + } else { + displayName = "-" + } + + text[i] = displayName + " (" + values[i] + ")"; + } + + text.unshift("-"); + values.unshift("-"); + + var select = document.createElement("SELECT") + for (let i = 0; i < text.length; i++) { + var option = document.createElement("OPTION") + option.setAttribute("value", values[i]) + option.innerText = text[i] + select.appendChild(option) + } + + if(set != "") { + (select as HTMLSelectElement).value = set + } + + if ((select as HTMLSelectElement).value != set) { + (select as HTMLSelectElement).value = "-" + } + + return select + } + + return +} + +function getValueFromProviderFile(file:string, fileType, key) { + + if (file == "xTeVe Dummy") { + return file + } + + var fileID:string + var indicator = file.charAt(0) + + switch (indicator) { + case "M": + fileType = "m3u" + fileID = file + break; + + case "H": + fileType = "hdhr" + fileID = file + break; + + case "X": + fileType = "xmltv" + fileID = file.substring(0, file.lastIndexOf('.')) + break; + + } + + if (SERVER["settings"]["files"][fileType].hasOwnProperty(fileID) == true) { + var data = SERVER["settings"]["files"][fileType][fileID]; + return data[key] + } + + return + +} + +function setXmltvChannel(id, element) { + + var xmltv:XMLTVFile = new XMLTVFile() + var xmlFile = element.value + + var tvgId:string = SERVER["xepg"]["epgMapping"][id]["tvg-id"] + var td = document.getElementById("popup-mapping").parentElement + td.innerHTML = "" + + var select = xmltv.getPrograms(element.value, tvgId) + select.setAttribute("name", "x-mapping") + select.setAttribute("id", "popup-mapping") + markChangedOnChange(select, function(element) { checkXmltvChannel(id, element, xmlFile) }) + select.className = "changed" + sortSelect(select) + td.appendChild(select); + + checkXmltvChannel(id, select, xmlFile) +} + +function checkXmltvChannel(id:string, element:any, xmlFile) { + + var value = (element as HTMLSelectElement).value + var bool:boolean + var checkbox = document.getElementById('active') + var channel:any = SERVER["xepg"]["epgMapping"][id] + var updateLogo:boolean + + + if (value == "-") { + bool = false + } else { + bool = true + } + + (checkbox as HTMLInputElement).checked = bool + checkbox.className = "changed" + console.log(xmlFile); + + // Kanallogo aktualisieren + /* + updateLogo = (document.getElementById("update-icon") as HTMLInputElement).checked + console.log(updateLogo); + */ + + if(xmlFile != "xTeVe Dummy" && bool == true) { + + //(document.getElementById("update-icon") as HTMLInputElement).checked = true; + //(document.getElementById("update-icon") as HTMLInputElement).className = "changed"; + + console.log("ID", id) + changeChannelLogo(id) + + return + } + + if (xmlFile == "xTeVe Dummy") { + (document.getElementById("update-icon") as HTMLInputElement).checked = false; + (document.getElementById("update-icon") as HTMLInputElement).className = "changed"; + } + + return +} + +function changeChannelLogo(id:string) { + + var updateLogo:boolean + var channel:any = SERVER["xepg"]["epgMapping"][id] + + var f = (document.getElementById("popup-xmltv") as HTMLSelectElement); + var xmltvFile = f.options[f.selectedIndex].value; + + var m = (document.getElementById("popup-mapping") as HTMLSelectElement); + var xMapping = m.options[m.selectedIndex].value; + + var xmltvLogo = SERVER["xepg"]["xmltvMap"][xmltvFile][xMapping]["icon"] + updateLogo = (document.getElementById("update-icon") as HTMLInputElement).checked + + if (updateLogo == true && xmltvFile != "xTeVe Dummy") { + + if (SERVER["xepg"]["xmltvMap"][xmltvFile].hasOwnProperty(xMapping)) { + var logo = xmltvLogo + } else { + logo = channel["tvg-logo"] + } + + var logoInput = (document.getElementById("channel-icon") as HTMLInputElement); + logoInput.value = logo + if (BULK_EDIT == false) { + logoInput.className = "changed" + } + + } + +}