Compare commits
7
Commits
3a3ba861dc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d385e68b31 | ||
|
|
e1a6956657 | ||
|
|
6a23996c38 | ||
|
|
0b26aa2029 | ||
|
|
014a3b04d2 | ||
|
|
3532679235 | ||
|
|
0b7a54d7da |
No files matched your search
+19
-5
@@ -8,7 +8,7 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
- go mod download
|
- go mod download
|
||||||
- go vet ./...
|
- go vet ./...
|
||||||
- test -z "$(gofmt -l .)" || (echo "gofmt needed:" && gofmt -l . && exit 1)
|
- 'test -z "$(gofmt -l .)" || (echo "gofmt needed" && gofmt -l . && exit 1)'
|
||||||
- go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...
|
- go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...
|
||||||
- go test ./...
|
- go test ./...
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ steps:
|
|||||||
- cp html/js/app.js /tmp/app.js.committed
|
- cp html/js/app.js /tmp/app.js.committed
|
||||||
- npm ci --no-audit --no-fund
|
- npm ci --no-audit --no-fund
|
||||||
- npm run build
|
- 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)
|
- 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
|
- name: go-build
|
||||||
@@ -26,7 +27,7 @@ steps:
|
|||||||
- XTEVE_VERSION="$(grep -m1 '^#### ' changelog-beta.md | cut -d' ' -f2 | sed 's/-beta$//')"
|
- XTEVE_VERSION="$(grep -m1 '^#### ' changelog-beta.md | cut -d' ' -f2 | sed 's/-beta$//')"
|
||||||
- test -n "$XTEVE_VERSION" || (echo "Could not parse version from changelog-beta.md" && exit 1)
|
- test -n "$XTEVE_VERSION" || (echo "Could not parse version from changelog-beta.md" && exit 1)
|
||||||
- SRC_VERSION="$(grep -m1 '^var Version = ' xteve.go | cut -d'"' -f2)"
|
- SRC_VERSION="$(grep -m1 '^var Version = ' xteve.go | cut -d'"' -f2)"
|
||||||
- test "$SRC_VERSION" = "$XTEVE_VERSION" || (echo "Version drift: xteve.go has $SRC_VERSION, changelog-beta.md has $XTEVE_VERSION" && exit 1)
|
- 'test "$SRC_VERSION" = "$XTEVE_VERSION" || (echo "Version drift - xteve.go has $SRC_VERSION, changelog-beta.md has $XTEVE_VERSION" && exit 1)'
|
||||||
- echo "Building xTeVe version $XTEVE_VERSION from changelog-beta.md"
|
- echo "Building xTeVe version $XTEVE_VERSION from changelog-beta.md"
|
||||||
- go build -v -ldflags "-X main.Version=$XTEVE_VERSION" ./...
|
- go build -v -ldflags "-X main.Version=$XTEVE_VERSION" ./...
|
||||||
|
|
||||||
@@ -50,6 +51,22 @@ steps:
|
|||||||
event:
|
event:
|
||||||
- pull_request
|
- pull_request
|
||||||
|
|
||||||
|
# plugins/docker reads its tag list from a .tags file when no "tags"
|
||||||
|
# setting is given. Master images get latest, the commit SHA and the
|
||||||
|
# release version from changelog-beta.md, so a release can be pulled by name.
|
||||||
|
- name: image-tags
|
||||||
|
image: cache.coadcorp.com/library/golang:1.27.1
|
||||||
|
commands:
|
||||||
|
- XTEVE_VERSION="$(grep -m1 '^#### ' changelog-beta.md | cut -d' ' -f2 | sed 's/-beta$//')"
|
||||||
|
- test -n "$XTEVE_VERSION"
|
||||||
|
- printf '%s' "latest,${DRONE_COMMIT_SHA},$XTEVE_VERSION" > .tags
|
||||||
|
- cat .tags
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
branch:
|
||||||
|
- master
|
||||||
|
|
||||||
- name: docker-publish
|
- name: docker-publish
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
environment:
|
environment:
|
||||||
@@ -62,9 +79,6 @@ steps:
|
|||||||
username: nathan
|
username: nathan
|
||||||
password:
|
password:
|
||||||
from_secret: registry_password
|
from_secret: registry_password
|
||||||
tags:
|
|
||||||
- latest
|
|
||||||
- ${DRONE_COMMIT_SHA}
|
|
||||||
build_args:
|
build_args:
|
||||||
- TARGETOS=linux
|
- TARGETOS=linux
|
||||||
- TARGETARCH=amd64
|
- TARGETARCH=amd64
|
||||||
|
|||||||
+3
-1
@@ -19,7 +19,9 @@ RUN XTEVE_VERSION="$(grep -m1 '^#### ' changelog-beta.md | cut -d' ' -f2 | sed '
|
|||||||
|
|
||||||
# Static ffmpeg/ffprobe binaries. To bump, pick a tag from
|
# Static ffmpeg/ffprobe binaries. To bump, pick a tag from
|
||||||
# https://hub.docker.com/r/mwader/static-ffmpeg/tags and change it here.
|
# https://hub.docker.com/r/mwader/static-ffmpeg/tags and change it here.
|
||||||
FROM mwader/static-ffmpeg:7.1.1 AS ffmpeg
|
# Do not go below 8.x: 7.1.1 rejects HLS segments with extensions such as .ec3
|
||||||
|
# ("not in allowed_extensions"), which breaks streams with E-AC-3 audio.
|
||||||
|
FROM mwader/static-ffmpeg:9.0.2 AS ffmpeg
|
||||||
|
|
||||||
FROM alpine:3.23
|
FROM alpine:3.23
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -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.
|
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
|
```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 build # ts/*.ts -> html/js/app.js
|
||||||
npm run check # type-check only
|
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.
|
English strings are inlined in the TypeScript. There is no language layer.
|
||||||
|
|
||||||
@@ -50,7 +53,7 @@ Add `-dev` to serve the web interface from the local `html/` directory instead o
|
|||||||
|
|
||||||
The version lives in two places that must agree: `var Version` in `xteve.go` and the first `#### ` heading in `changelog-beta.md` (without the `-beta` suffix). CI fails on drift.
|
The version lives in two places that must agree: `var Version` in `xteve.go` and the first `#### ` heading in `changelog-beta.md` (without the `-beta` suffix). CI fails on drift.
|
||||||
|
|
||||||
Drone (`.drone.yml`) runs vet, tests, the bundle check, hadolint and compose validation on every push. Pushes to `master` publish `registry.coadcorp.com/nathan/xteve:latest` and `:<sha>`; other branches publish `:<branch>` and `:<sha>`.
|
Drone (`.drone.yml`) runs vet, tests, the bundle check, hadolint and compose validation on every push. Pushes to `master` publish `registry.coadcorp.com/nathan/xteve:latest`, `:<version>` and `:<sha>`; other branches publish `:<branch>` and `:<sha>`.
|
||||||
|
|
||||||
## Endpoints useful for operations
|
## Endpoints useful for operations
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ registry.coadcorp.com/nathan/xteve
|
|||||||
|
|
||||||
Tags:
|
Tags:
|
||||||
|
|
||||||
* `latest` and `<commit sha>` from the `master` branch.
|
* `latest`, the release version (for example `2.3.0.0202`) and `<commit sha>` from the `master` branch. Pin the version tag if you want to control when you upgrade, or to roll back.
|
||||||
* `<branch name>` and `<commit sha>` from every other branch.
|
* `<branch name>` and `<commit sha>` from every other branch.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,3 +1,20 @@
|
|||||||
|
#### 2.3.0.0202-beta
|
||||||
|
```diff
|
||||||
|
+ Security: provider-supplied text is rendered as text in the web UI; websocket requests must come from the same origin; the session cookie is HttpOnly and SameSite=Strict; logo uploads, backup restores, the ffmpeg/VLC path and stream URLs are validated; backups can only be downloaded with a web session.
|
||||||
|
+ Passwords are stored with bcrypt. Existing accounts are upgraded on their next login.
|
||||||
|
+ Streaming: buffer state rewritten around a single lock with an atomic tuner limit; ffmpeg/VLC are stopped when the last client leaves or when no data arrives within 20 seconds; long streams no longer accumulate open handles.
|
||||||
|
+ Per-playlist buffer choice (default, none, xTeVe, FFmpeg, VLC) in the playlist dialog.
|
||||||
|
+ New filter type: regular expression.
|
||||||
|
+ Web UI: one persistent websocket with automatic reconnect, light theme following the OS setting, focus is kept when the menu refreshes, errors are announced to screen readers.
|
||||||
|
+ The web interface is embedded with go:embed and built from a pinned TypeScript toolchain; the language file is gone (English only).
|
||||||
|
+ New /healthz endpoint, used by the container healthcheck.
|
||||||
|
+ Container: PUID/PGID are applied at start-up; ffmpeg 9.0.2; images are also tagged with the release version.
|
||||||
|
+ Settings and other state files are written atomically; settings.json is readable by the owner only.
|
||||||
|
+ HTTP server and provider downloads have timeouts.
|
||||||
|
- Removed the built-in self-updater. Updates are delivered through the container image.
|
||||||
|
- Removed XTEVE_UID/XTEVE_GID as the documented variables (still accepted as a fallback for PUID/PGID).
|
||||||
|
```
|
||||||
|
|
||||||
#### 2.2.0.0201-beta
|
#### 2.2.0.0201-beta
|
||||||
Add UI enhancement for range selecting checkboxes
|
Add UI enhancement for range selecting checkboxes
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>xTeVe</title>
|
<title>xTeVe</title>
|
||||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
<link rel="stylesheet" href="css/tokens.css">
|
||||||
<link rel="stylesheet" href="css/base.css" type="text/css">
|
<link rel="stylesheet" href="css/base.css">
|
||||||
|
<link rel="stylesheet" href="css/components.css">
|
||||||
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="header" class="imgCenter"></div>
|
<div id="header" class="imgCenter"></div>
|
||||||
<main id="box" role="main" aria-labelledby="head-text">
|
<main id="box" aria-labelledby="head-text">
|
||||||
|
|
||||||
<table id="clientInfo" class="visible" aria-label="Server information">
|
<table id="clientInfo" class="visible" aria-label="Server information">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>xTeVe</title>
|
<title>xTeVe</title>
|
||||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
<link rel="stylesheet" href="css/tokens.css">
|
||||||
<link rel="stylesheet" href="css/base.css" type="text/css">
|
<link rel="stylesheet" href="css/base.css">
|
||||||
|
<link rel="stylesheet" href="css/components.css">
|
||||||
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@
|
|||||||
|
|
||||||
<div id="header" class="imgCenter"></div>
|
<div id="header" class="imgCenter"></div>
|
||||||
|
|
||||||
<main id="box" role="main" aria-labelledby="head-text">
|
<main id="box" aria-labelledby="head-text">
|
||||||
|
|
||||||
<div id="headline">
|
<div id="headline">
|
||||||
<h1 id="head-text" class="center">Create user account</h1>
|
<h1 id="head-text" class="center">Create user account</h1>
|
||||||
|
|||||||
+46
-492
@@ -1,32 +1,14 @@
|
|||||||
:root {
|
/*
|
||||||
--bg-0: #07111d;
|
* base.css - reset, typography, links, focus styles, skip link, generic
|
||||||
--bg-1: #0c1a2b;
|
* form elements and small utility classes. Loaded after tokens.css.
|
||||||
--bg-2: #12233a;
|
*/
|
||||||
--panel: #102238;
|
|
||||||
--panel-soft: #0f1f33;
|
|
||||||
--line: #274462;
|
|
||||||
--line-soft: #1b334d;
|
|
||||||
--text: #e9f5ff;
|
|
||||||
--text-muted: #b7cee1;
|
|
||||||
--accent: #35d2ff;
|
|
||||||
--accent-strong: #12b9ff;
|
|
||||||
--accent-soft: rgba(53, 210, 255, 0.2);
|
|
||||||
--ok: #2fd18a;
|
|
||||||
--warn: #ffc15a;
|
|
||||||
--error: #ff6f6f;
|
|
||||||
--radius-s: 10px;
|
|
||||||
--radius-m: 14px;
|
|
||||||
--radius-l: 18px;
|
|
||||||
--shadow-1: 0 20px 45px rgba(0, 0, 0, 0.32);
|
|
||||||
--shadow-2: 0 12px 30px rgba(0, 0, 0, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
-moz-appearance: none;
|
-moz-appearance: none;
|
||||||
-ms-appearance: none;
|
-ms-appearance: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: "Space Grotesk", "Avenir Next", "Trebuchet MS", sans-serif;
|
font-family: var(--font-sans);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,17 +19,17 @@
|
|||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(7, 17, 29, 0.65);
|
background: var(--scroll-track);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 2px solid rgba(7, 17, 29, 0.6);
|
border: 2px solid var(--scroll-track);
|
||||||
background: linear-gradient(180deg, #2d597f, #1e4060);
|
background: linear-gradient(180deg, var(--scroll-thumb-a), var(--scroll-thumb-b));
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: linear-gradient(180deg, #3678ac, #20537c);
|
background: linear-gradient(180deg, var(--scroll-thumb-hover-a), var(--scroll-thumb-hover-b));
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-corner {
|
::-webkit-scrollbar-corner {
|
||||||
@@ -61,9 +43,9 @@ body {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 15% 12%, rgba(64, 132, 183, 0.22), transparent 32%),
|
radial-gradient(circle at 15% 12%, var(--bg-glow-1), transparent 32%),
|
||||||
radial-gradient(circle at 88% 2%, rgba(18, 185, 255, 0.16), transparent 30%),
|
radial-gradient(circle at 88% 2%, var(--bg-glow-2), transparent 30%),
|
||||||
linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 56%, #060d17 100%);
|
linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 56%, var(--bg-deep) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -75,7 +57,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
color: #8de7ff;
|
color: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -122,7 +104,7 @@ pre {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
letter-spacing: 0.015em;
|
letter-spacing: 0.015em;
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", "Consolas", monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
hr {
|
hr {
|
||||||
@@ -153,7 +135,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid #91eaff;
|
outline: 2px solid var(--focus-ring-soft);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +153,7 @@ textarea {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background-color: rgba(10, 22, 36, 0.78);
|
background-color: var(--surface-input);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 10px 11px;
|
padding: 10px 11px;
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||||
@@ -184,45 +166,15 @@ input[type=number]:focus,
|
|||||||
select:focus,
|
select:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px rgba(53, 210, 255, 0.17);
|
box-shadow: 0 0 0 3px var(--accent-ring);
|
||||||
background-color: rgba(9, 25, 40, 0.95);
|
background-color: var(--surface-input-focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=text]::placeholder,
|
input[type=text]::placeholder,
|
||||||
input[type=search]::placeholder,
|
input[type=search]::placeholder,
|
||||||
input[type=password]::placeholder,
|
input[type=password]::placeholder,
|
||||||
textarea::placeholder {
|
textarea::placeholder {
|
||||||
color: #7e9ab4;
|
color: var(--text-placeholder);
|
||||||
}
|
|
||||||
|
|
||||||
input[type=button],
|
|
||||||
input[type=submit],
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 999px;
|
|
||||||
color: #03101a;
|
|
||||||
padding: 10px 18px;
|
|
||||||
margin: 8px 8px 8px 0;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
background: linear-gradient(135deg, #67e8ff 0%, #12b9ff 100%);
|
|
||||||
box-shadow: 0 10px 22px rgba(18, 185, 255, 0.3);
|
|
||||||
transition: transform 0.15s ease, box-shadow 0.2s ease, filter 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=button]:hover,
|
|
||||||
input[type=submit]:hover,
|
|
||||||
button:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
filter: brightness(1.05);
|
|
||||||
box-shadow: 0 12px 24px rgba(18, 185, 255, 0.36);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=button]:active,
|
|
||||||
input[type=submit]:active,
|
|
||||||
button:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=button]:focus-visible,
|
input[type=button]:focus-visible,
|
||||||
@@ -236,80 +188,43 @@ input[type=text]:focus-visible,
|
|||||||
input[type=search]:focus-visible,
|
input[type=search]:focus-visible,
|
||||||
input[type=password]:focus-visible,
|
input[type=password]:focus-visible,
|
||||||
input[type=number]:focus-visible {
|
input[type=number]:focus-visible {
|
||||||
outline: 2px solid #7de5ff;
|
outline: 2px solid var(--focus-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=button].delete {
|
.skip-link {
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(135deg, #ff7f7f 0%, #e94343 100%);
|
|
||||||
box-shadow: 0 10px 20px rgba(233, 67, 67, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=button].cancel {
|
|
||||||
color: #ffd8d8;
|
|
||||||
border-color: rgba(255, 111, 111, 0.45);
|
|
||||||
background: rgba(87, 22, 22, 0.45);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=button].black,
|
|
||||||
input[type=submit].black {
|
|
||||||
color: #d8ecff;
|
|
||||||
border-color: var(--line);
|
|
||||||
background: rgba(14, 32, 50, 0.85);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=button].center {
|
|
||||||
margin-right: auto;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=button].save {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=checkbox] {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 6px;
|
|
||||||
background-color: rgba(10, 22, 36, 0.86);
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=checkbox]:checked {
|
|
||||||
border-color: #89ebff;
|
|
||||||
background-color: #39d6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=checkbox]:before {
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=checkbox]:checked:before {
|
|
||||||
content: "\2713";
|
|
||||||
color: #032033;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 800;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -1px;
|
top: 10px;
|
||||||
left: 3px;
|
left: 10px;
|
||||||
|
z-index: 2600;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
transform: translateY(-180%);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.changed {
|
.skip-link:focus-visible {
|
||||||
border-color: #8de7ff !important;
|
transform: translateY(0);
|
||||||
box-shadow: 0 0 0 3px rgba(53, 210, 255, 0.2) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notAvailable {
|
.sr-only {
|
||||||
opacity: 0.58;
|
border: 0 !important;
|
||||||
cursor: not-allowed;
|
clip: rect(0 0 0 0) !important;
|
||||||
border-color: rgba(255, 111, 111, 0.45) !important;
|
height: 1px !important;
|
||||||
|
margin: -1px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
position: absolute !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
width: 1px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
|
||||||
.pointer {
|
.pointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -364,37 +279,6 @@ input[type=checkbox]:checked:before {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skip-link {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
z-index: 2600;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #03131f;
|
|
||||||
background: linear-gradient(135deg, #7be8ff 0%, #19c1ff 100%);
|
|
||||||
transform: translateY(-180%);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-link:focus-visible {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
border: 0 !important;
|
|
||||||
clip: rect(0 0 0 0) !important;
|
|
||||||
height: 1px !important;
|
|
||||||
margin: -1px !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
position: absolute !important;
|
|
||||||
white-space: nowrap !important;
|
|
||||||
width: 1px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floatRight {
|
.floatRight {
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
@@ -423,9 +307,6 @@ input[type=checkbox]:checked:before {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.noBulk {
|
|
||||||
}
|
|
||||||
|
|
||||||
.notVisible {
|
.notVisible {
|
||||||
display: none;
|
display: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -437,259 +318,10 @@ input[type=checkbox]:checked:before {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-active {
|
|
||||||
color: #011019;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-notActive {
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.half {
|
.half {
|
||||||
width: 45%;
|
width: 45%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenLogHidden {
|
|
||||||
transform: translate(0, -110px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.infoMsg {
|
|
||||||
color: #87b0d1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorMsg {
|
|
||||||
color: #ff8d8d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warningMsg {
|
|
||||||
color: var(--warn);
|
|
||||||
}
|
|
||||||
|
|
||||||
.debugMsg {
|
|
||||||
color: #d687ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.News,
|
|
||||||
.Movie,
|
|
||||||
.Series,
|
|
||||||
.Sports,
|
|
||||||
.Kids {
|
|
||||||
border-left: 3px solid transparent;
|
|
||||||
padding-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.News {
|
|
||||||
border-color: #ff8f6e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Movie {
|
|
||||||
border-color: #46a4ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Series {
|
|
||||||
border-color: #ffd267;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Sports {
|
|
||||||
border-color: #5ee495;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Kids {
|
|
||||||
border-color: #f6a8ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
#loading {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 2000;
|
|
||||||
background-color: rgba(5, 12, 20, 0.72);
|
|
||||||
backdrop-filter: blur(1.5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader {
|
|
||||||
width: 52px;
|
|
||||||
height: 52px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 4px solid transparent;
|
|
||||||
border-top-color: #58ddff;
|
|
||||||
border-right-color: #12b9ff;
|
|
||||||
animation: spin 0.9s linear infinite;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background-color: rgba(3, 9, 16, 0.72);
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
z-index: 1500;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup-custom,
|
|
||||||
#mapping-detail,
|
|
||||||
#user-detail,
|
|
||||||
#file-detail {
|
|
||||||
margin: 0 auto;
|
|
||||||
max-width: 760px;
|
|
||||||
max-height: calc(100vh - 36px);
|
|
||||||
overflow: auto;
|
|
||||||
border-radius: var(--radius-l);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: linear-gradient(180deg, rgba(17, 34, 56, 0.97) 0%, rgba(12, 27, 44, 0.97) 100%);
|
|
||||||
box-shadow: var(--shadow-1);
|
|
||||||
padding: 16px;
|
|
||||||
animation: popupIn 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup-custom h3 {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
text-align: center;
|
|
||||||
color: #d5efff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-title h3 {
|
|
||||||
margin: 0;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup-custom .popup-title h3 {
|
|
||||||
margin: 0;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-close {
|
|
||||||
min-width: 40px;
|
|
||||||
min-height: 40px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
color: #d9f0ff;
|
|
||||||
background: rgba(14, 32, 50, 0.9);
|
|
||||||
box-shadow: none;
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-close:hover {
|
|
||||||
color: #ffffff;
|
|
||||||
border-color: #5bc8ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup-custom table,
|
|
||||||
#content_settings table,
|
|
||||||
#mapping-detail-table,
|
|
||||||
#user-detail-table {
|
|
||||||
width: 100%;
|
|
||||||
table-layout: fixed;
|
|
||||||
border-collapse: separate;
|
|
||||||
border-spacing: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup-custom td,
|
|
||||||
#content_settings td,
|
|
||||||
#mapping-detail-table td,
|
|
||||||
#user-detail-table td {
|
|
||||||
padding: 2px 6px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup-custom td.left,
|
|
||||||
#mapping-detail-table td.left,
|
|
||||||
#user-detail-table td.left {
|
|
||||||
width: 38%;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup-custom input[type=text],
|
|
||||||
#popup-custom input[type=password],
|
|
||||||
#mapping-detail input[type=text],
|
|
||||||
#content_settings input[type=text],
|
|
||||||
#content_settings input[type=password] {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mapping-detail img {
|
|
||||||
display: block;
|
|
||||||
max-height: 44px;
|
|
||||||
margin: 8px auto 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#file-detail input[type=text] {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interaction,
|
|
||||||
#interaction {
|
|
||||||
margin-top: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interaction input[type=button],
|
|
||||||
.interaction input[type=submit] {
|
|
||||||
margin: 0;
|
|
||||||
min-width: 110px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup-interaction {
|
|
||||||
margin-top: 14px;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#notification {
|
|
||||||
position: fixed;
|
|
||||||
right: 12px;
|
|
||||||
top: 12px;
|
|
||||||
width: 260px;
|
|
||||||
max-height: calc(100vh - 24px);
|
|
||||||
overflow: auto;
|
|
||||||
border-radius: var(--radius-m);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: rgba(10, 22, 36, 0.92);
|
|
||||||
box-shadow: var(--shadow-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#notification .element {
|
|
||||||
margin: 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border-left: 4px solid var(--ok);
|
|
||||||
background: rgba(17, 35, 56, 0.84);
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#notification h5 {
|
|
||||||
padding: 0;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#notification p {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableEllipsis {
|
.tableEllipsis {
|
||||||
width: 150px;
|
width: 150px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -718,81 +350,3 @@ input[type=checkbox]:checked:before {
|
|||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 619px) {
|
|
||||||
h1 {
|
|
||||||
font-size: 1.46rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup-custom,
|
|
||||||
#mapping-detail,
|
|
||||||
#user-detail,
|
|
||||||
#file-detail {
|
|
||||||
max-width: none;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
border-radius: 0;
|
|
||||||
border: 0;
|
|
||||||
padding: 10px 10px calc(14px + env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup-custom table {
|
|
||||||
border-spacing: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup-custom tr {
|
|
||||||
display: block;
|
|
||||||
border: 1px solid var(--line-soft);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 8px 8px 10px;
|
|
||||||
background: rgba(12, 26, 42, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup-custom td {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 4px 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup-custom td.left {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#popup-interaction {
|
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 5;
|
|
||||||
margin-top: 10px;
|
|
||||||
padding-top: 10px;
|
|
||||||
background: linear-gradient(180deg, rgba(12, 27, 44, 0.1) 0%, rgba(12, 27, 44, 0.96) 30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.interaction,
|
|
||||||
#interaction {
|
|
||||||
justify-content: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interaction input[type=button],
|
|
||||||
.interaction input[type=submit],
|
|
||||||
#popup-interaction input[type=button] {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-title h3 {
|
|
||||||
font-size: 1.02rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-close {
|
|
||||||
min-width: 44px;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,775 @@
|
|||||||
|
/*
|
||||||
|
* components.css - buttons, inputs, tables, status cards, connection
|
||||||
|
* indicator, popups, menu, log lines and badges. Loaded after base.css.
|
||||||
|
* Responsive overrides for these components live in layout.css.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
|
||||||
|
input[type=button],
|
||||||
|
input[type=submit],
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
padding: 10px 18px;
|
||||||
|
margin: 8px 8px 8px 0;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
box-shadow: 0 10px 22px var(--accent-shadow);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.2s ease, filter 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=button]:hover,
|
||||||
|
input[type=submit]:hover,
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
filter: brightness(1.05);
|
||||||
|
box-shadow: 0 12px 24px var(--accent-shadow-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=button]:active,
|
||||||
|
input[type=submit]:active,
|
||||||
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=button].delete {
|
||||||
|
color: var(--text-on-error);
|
||||||
|
background: linear-gradient(135deg, var(--error-bright) 0%, var(--error-strong) 100%);
|
||||||
|
box-shadow: 0 10px 20px var(--error-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=button].cancel {
|
||||||
|
color: var(--error-text-soft);
|
||||||
|
border-color: var(--error-soft);
|
||||||
|
background: var(--error-surface);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=button].black,
|
||||||
|
input[type=submit].black {
|
||||||
|
color: var(--text-heading);
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=button].center {
|
||||||
|
margin-right: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=button].save {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox */
|
||||||
|
|
||||||
|
input[type=checkbox] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--surface-input);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=checkbox]:checked {
|
||||||
|
border-color: var(--checkbox-checked-border);
|
||||||
|
background-color: var(--checkbox-checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=checkbox]:before {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=checkbox]:checked:before {
|
||||||
|
content: "\2713";
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input states */
|
||||||
|
|
||||||
|
.changed {
|
||||||
|
border-color: var(--accent-hover) !important;
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notAvailable {
|
||||||
|
opacity: 0.58;
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: var(--error-soft) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar menu */
|
||||||
|
|
||||||
|
#branch {
|
||||||
|
min-height: 18px;
|
||||||
|
margin: 2px 0 6px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--error-text);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-menu li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 11px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: var(--surface-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-menu li:hover {
|
||||||
|
border-color: var(--accent-soft);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-menu li:focus-visible {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
transform: translateX(2px);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-menu li.menu-active {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
box-shadow: 0 10px 18px var(--accent-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-menu li.menu-active p {
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-menu li.menu-active img {
|
||||||
|
filter: brightness(0.04) saturate(120%);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav img {
|
||||||
|
display: block;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
max-width: 18px;
|
||||||
|
max-height: 18px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav p {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-heading);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-active {
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection indicator */
|
||||||
|
|
||||||
|
#connection-indicator {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 5px 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
#connection-indicator.status-idle {
|
||||||
|
color: var(--text-heading);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
#connection-indicator.status-busy {
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border: 1px solid var(--accent-bright);
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
#connection-indicator.status-online {
|
||||||
|
color: var(--text-on-ok);
|
||||||
|
border: 1px solid var(--ok-border);
|
||||||
|
background: linear-gradient(135deg, var(--ok-bright) 0%, var(--ok-mid) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#connection-indicator.status-offline {
|
||||||
|
color: var(--text-on-error);
|
||||||
|
border: 1px solid var(--error-border);
|
||||||
|
background: linear-gradient(135deg, var(--error-bright) 0%, var(--error-strong) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Server info and stream tables */
|
||||||
|
|
||||||
|
#clientInfo,
|
||||||
|
#activeStreams,
|
||||||
|
#inactiveStreams {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 7px;
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#clientInfo {
|
||||||
|
padding: 7px 10px 6px;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
#clientInfo tr {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clientInfo .tdKey,
|
||||||
|
#clientInfo .tdVal,
|
||||||
|
#activeStreams .tdKey,
|
||||||
|
#inactiveStreams .tdKey,
|
||||||
|
#activeStreams .tdVal,
|
||||||
|
#inactiveStreams .tdVal,
|
||||||
|
#logInfo .tdVal,
|
||||||
|
#mappingInfo .tdVal {
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
#clientInfo .tdKey,
|
||||||
|
#activeStreams .tdKey,
|
||||||
|
#inactiveStreams .tdKey {
|
||||||
|
color: var(--accent-text);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clientInfo .tdVal,
|
||||||
|
#activeStreams .tdVal,
|
||||||
|
#inactiveStreams .tdVal {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clientInfo .tdVal a {
|
||||||
|
color: var(--accent-text);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dotted var(--link-underline);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clientInfo .tdVal a:hover,
|
||||||
|
#clientInfo .tdVal a:focus {
|
||||||
|
color: var(--accent-text-hover);
|
||||||
|
border-bottom-color: var(--accent-text-hover);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#myStreamsBox {
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
background: var(--surface-1);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#allStreams {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#activeStreams,
|
||||||
|
#inactiveStreams {
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inactiveStreams .tdKey {
|
||||||
|
color: var(--error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status cards */
|
||||||
|
|
||||||
|
.dashboard-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(130px, 1fr));
|
||||||
|
gap: 9px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 74px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card-label {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card-value {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card.status-card-ok {
|
||||||
|
border-color: var(--ok-soft);
|
||||||
|
background: linear-gradient(180deg, var(--ok-surface-top) 0%, var(--ok-surface-bottom) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card.status-card-warn {
|
||||||
|
border-color: var(--warn-soft);
|
||||||
|
background: linear-gradient(180deg, var(--warn-surface-top) 0%, var(--warn-surface-bottom) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card.status-card-error {
|
||||||
|
border-color: var(--error-soft);
|
||||||
|
background: linear-gradient(180deg, var(--error-surface-top) 0%, var(--error-surface-bottom) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content table */
|
||||||
|
|
||||||
|
#box-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_table .content_table_header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: linear-gradient(180deg, var(--table-head-top), var(--table-head-bottom));
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_table .content_table_header td {
|
||||||
|
color: var(--accent-text);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
padding: 10px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_table tr {
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
border-bottom: 1px solid var(--row-line);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_table tr:hover {
|
||||||
|
background-color: var(--row-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_table tr[tabindex],
|
||||||
|
#content_table td[tabindex] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_table tr[tabindex]:focus-visible,
|
||||||
|
#content_table td[tabindex]:focus-visible,
|
||||||
|
.keyboard-clickable:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: -2px;
|
||||||
|
background-color: var(--row-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_table td {
|
||||||
|
padding: 7px 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_table img {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
max-width: 34px;
|
||||||
|
max-height: 30px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_table input[type=text] {
|
||||||
|
min-width: 48px;
|
||||||
|
max-width: 76px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 7px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_table input[type=checkbox] {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_table .mapping-edit-button {
|
||||||
|
min-height: 32px;
|
||||||
|
min-width: 64px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_table tr.activeEPG {
|
||||||
|
border-left-color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_table tr.notActiveEPG {
|
||||||
|
border-left-color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings */
|
||||||
|
|
||||||
|
#settings {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_settings h4 {
|
||||||
|
margin: 15px 0 8px;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_settings table {
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_settings td {
|
||||||
|
border-bottom: 1px solid var(--row-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_settings tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_settings pre {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popup and detail tables */
|
||||||
|
|
||||||
|
#popup-custom table,
|
||||||
|
#content_settings table,
|
||||||
|
#mapping-detail-table,
|
||||||
|
#user-detail-table {
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-custom td,
|
||||||
|
#content_settings td,
|
||||||
|
#mapping-detail-table td,
|
||||||
|
#user-detail-table td {
|
||||||
|
padding: 2px 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-custom td.left,
|
||||||
|
#mapping-detail-table td.left,
|
||||||
|
#user-detail-table td.left {
|
||||||
|
width: 38%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-custom input[type=text],
|
||||||
|
#popup-custom input[type=password],
|
||||||
|
#mapping-detail input[type=text],
|
||||||
|
#content_settings input[type=text],
|
||||||
|
#content_settings input[type=password] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mapping-detail img {
|
||||||
|
display: block;
|
||||||
|
max-height: 44px;
|
||||||
|
margin: 8px auto 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-detail input[type=text] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading and popups */
|
||||||
|
|
||||||
|
#loading {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
background-color: var(--overlay);
|
||||||
|
backdrop-filter: blur(1.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-top-color: var(--loader);
|
||||||
|
border-right-color: var(--accent-strong);
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: var(--overlay);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: 1500;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-custom,
|
||||||
|
#mapping-detail,
|
||||||
|
#user-detail,
|
||||||
|
#file-detail {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 760px;
|
||||||
|
max-height: calc(100vh - 36px);
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: var(--radius-l);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: linear-gradient(180deg, var(--panel-top) 0%, var(--panel-bottom) 100%);
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
padding: 16px;
|
||||||
|
animation: popupIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-custom h3 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-title h3 {
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-custom .popup-title h3 {
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-close {
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text-heading);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: none;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-close:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interaction,
|
||||||
|
#interaction {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interaction input[type=button],
|
||||||
|
.interaction input[type=submit] {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-interaction {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notifications */
|
||||||
|
|
||||||
|
#notification {
|
||||||
|
position: fixed;
|
||||||
|
right: 12px;
|
||||||
|
top: 12px;
|
||||||
|
width: 260px;
|
||||||
|
max-height: calc(100vh - 24px);
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface-solid);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#notification .element {
|
||||||
|
margin: 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 4px solid var(--ok);
|
||||||
|
background: var(--surface-3);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notification h5 {
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notification p {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log lines */
|
||||||
|
|
||||||
|
#content_log {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content_log pre {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logScreen p {
|
||||||
|
white-space: pre;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenLogHidden {
|
||||||
|
transform: translate(0, -110px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoMsg {
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMsg {
|
||||||
|
color: var(--error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningMsg {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.debugMsg {
|
||||||
|
color: var(--debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category badges */
|
||||||
|
|
||||||
|
.News,
|
||||||
|
.Movie,
|
||||||
|
.Series,
|
||||||
|
.Sports,
|
||||||
|
.Kids {
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.News {
|
||||||
|
border-color: #ff8f6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Movie {
|
||||||
|
border-color: #46a4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Series {
|
||||||
|
border-color: #ffd267;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Sports {
|
||||||
|
border-color: #5ee495;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Kids {
|
||||||
|
border-color: #f6a8ff;
|
||||||
|
}
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/*
|
||||||
|
* layout.css - shell layout (sidebar, header, content wrapper), the auth /
|
||||||
|
* wizard box pages and every responsive breakpoint. Loaded last.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Application shell */
|
||||||
|
|
||||||
#layout {
|
#layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 290px minmax(0, 1fr);
|
grid-template-columns: 290px minmax(0, 1fr);
|
||||||
@@ -15,8 +22,8 @@
|
|||||||
border-radius: var(--radius-l);
|
border-radius: var(--radius-l);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(18, 36, 58, 0.95) 0%, rgba(10, 23, 38, 0.95) 100%),
|
linear-gradient(180deg, var(--panel-top) 0%, var(--panel-bottom) 100%),
|
||||||
radial-gradient(circle at 20% 0%, rgba(53, 210, 255, 0.12), transparent 48%);
|
radial-gradient(circle at 20% 0%, var(--panel-glow), transparent 48%);
|
||||||
box-shadow: var(--shadow-2);
|
box-shadow: var(--shadow-2);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -28,100 +35,25 @@
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#branch {
|
|
||||||
min-height: 18px;
|
|
||||||
margin: 2px 0 6px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #ff9f8d;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logo {
|
#logo {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 76px;
|
height: 76px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid var(--line-soft);
|
border: 1px solid var(--line-soft);
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(26, 49, 73, 0.55), rgba(13, 28, 45, 0.6)),
|
linear-gradient(180deg, var(--logo-tint-top), var(--logo-tint-bottom)),
|
||||||
url("../img/logo_w_600x200.png") center no-repeat;
|
url("../img/logo_w_600x200.png") center no-repeat;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#main-menu {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 7px;
|
|
||||||
overflow: auto;
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#main-menu li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-height: 42px;
|
|
||||||
padding: 8px 11px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: rgba(11, 25, 40, 0.58);
|
|
||||||
}
|
|
||||||
|
|
||||||
#main-menu li:hover {
|
|
||||||
border-color: var(--accent-soft);
|
|
||||||
transform: translateX(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
#main-menu li:focus-visible {
|
|
||||||
border-color: rgba(103, 232, 255, 0.62);
|
|
||||||
transform: translateX(2px);
|
|
||||||
box-shadow: 0 0 0 3px rgba(53, 210, 255, 0.23);
|
|
||||||
}
|
|
||||||
|
|
||||||
#main-menu li.menu-active {
|
|
||||||
border-color: rgba(103, 232, 255, 0.55);
|
|
||||||
background: linear-gradient(135deg, #6fe6ff 0%, #1cc5ff 100%);
|
|
||||||
box-shadow: 0 10px 18px rgba(28, 197, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#main-menu li.menu-active p {
|
|
||||||
color: #03111a;
|
|
||||||
}
|
|
||||||
|
|
||||||
#main-menu li.menu-active img {
|
|
||||||
filter: brightness(0.04) saturate(120%);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav img {
|
|
||||||
display: block;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
max-width: 18px;
|
|
||||||
max-height: 18px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav p {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #d7eefe;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-right {
|
.layout-right {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-radius: var(--radius-l);
|
border-radius: var(--radius-l);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(17, 34, 54, 0.94) 0%, rgba(10, 20, 34, 0.96) 100%),
|
linear-gradient(180deg, var(--panel-top) 0%, var(--panel-bottom) 100%),
|
||||||
radial-gradient(circle at 100% 0%, rgba(53, 210, 255, 0.08), transparent 34%);
|
radial-gradient(circle at 100% 0%, var(--panel-glow), transparent 34%);
|
||||||
box-shadow: var(--shadow-2);
|
box-shadow: var(--shadow-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -134,14 +66,14 @@ nav p {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border-bottom: 1px solid var(--line-soft);
|
border-bottom: 1px solid var(--line-soft);
|
||||||
background: rgba(10, 22, 36, 0.52);
|
background: var(--surface-0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#shell-title {
|
#shell-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.08rem;
|
font-size: 1.08rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #d8f1ff;
|
color: var(--text-heading);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,176 +85,7 @@ nav p {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#connection-indicator {
|
/* Content wrapper */
|
||||||
margin-left: auto;
|
|
||||||
padding: 5px 11px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
#connection-indicator.status-idle {
|
|
||||||
color: #d8edff;
|
|
||||||
border: 1px solid #3a5877;
|
|
||||||
background: rgba(20, 42, 66, 0.65);
|
|
||||||
}
|
|
||||||
|
|
||||||
#connection-indicator.status-busy {
|
|
||||||
color: #072031;
|
|
||||||
border: 1px solid #7ee8ff;
|
|
||||||
background: linear-gradient(135deg, #7be8ff 0%, #33cdff 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
#connection-indicator.status-online {
|
|
||||||
color: #052219;
|
|
||||||
border: 1px solid #9ff0c8;
|
|
||||||
background: linear-gradient(135deg, #92f0c0 0%, #4fce93 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
#connection-indicator.status-offline {
|
|
||||||
color: #ffe5e5;
|
|
||||||
border: 1px solid #ff9e9e;
|
|
||||||
background: linear-gradient(135deg, #ff7e7e 0%, #d34d4d 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
#clientInfo,
|
|
||||||
#activeStreams,
|
|
||||||
#inactiveStreams {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: separate;
|
|
||||||
border-spacing: 7px;
|
|
||||||
background: rgba(8, 20, 33, 0.54);
|
|
||||||
}
|
|
||||||
|
|
||||||
#clientInfo {
|
|
||||||
padding: 7px 10px 6px;
|
|
||||||
border-bottom: 1px solid var(--line-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
#clientInfo tr {
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
#clientInfo .tdKey,
|
|
||||||
#clientInfo .tdVal,
|
|
||||||
#activeStreams .tdKey,
|
|
||||||
#inactiveStreams .tdKey,
|
|
||||||
#activeStreams .tdVal,
|
|
||||||
#inactiveStreams .tdVal,
|
|
||||||
#logInfo .tdVal,
|
|
||||||
#mappingInfo .tdVal {
|
|
||||||
border: 1px solid var(--line-soft);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 7px 8px;
|
|
||||||
vertical-align: middle;
|
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", "Consolas", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
#clientInfo .tdKey,
|
|
||||||
#activeStreams .tdKey,
|
|
||||||
#inactiveStreams .tdKey {
|
|
||||||
color: #53d9ff;
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
#clientInfo .tdVal,
|
|
||||||
#activeStreams .tdVal,
|
|
||||||
#inactiveStreams .tdVal {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
#clientInfo .tdVal a {
|
|
||||||
color: #8fdcff;
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: 1px dotted rgba(143, 220, 255, 0.6);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
#clientInfo .tdVal a:hover,
|
|
||||||
#clientInfo .tdVal a:focus {
|
|
||||||
color: #d7f4ff;
|
|
||||||
border-bottom-color: #d7f4ff;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-cards {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(6, minmax(130px, 1fr));
|
|
||||||
gap: 9px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-bottom: 1px solid var(--line-soft);
|
|
||||||
background: rgba(8, 18, 30, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card {
|
|
||||||
border: 1px solid var(--line-soft);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(13, 31, 49, 0.8);
|
|
||||||
padding: 10px;
|
|
||||||
min-height: 74px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card-label {
|
|
||||||
color: #8fb3cf;
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card-value {
|
|
||||||
color: #e8f5ff;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.status-card-ok {
|
|
||||||
border-color: rgba(67, 191, 130, 0.48);
|
|
||||||
background: linear-gradient(180deg, rgba(20, 54, 44, 0.9) 0%, rgba(12, 38, 31, 0.95) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.status-card-warn {
|
|
||||||
border-color: rgba(255, 193, 90, 0.5);
|
|
||||||
background: linear-gradient(180deg, rgba(68, 50, 20, 0.86) 0%, rgba(44, 32, 14, 0.96) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card.status-card-error {
|
|
||||||
border-color: rgba(255, 111, 111, 0.5);
|
|
||||||
background: linear-gradient(180deg, rgba(73, 26, 26, 0.84) 0%, rgba(47, 19, 19, 0.96) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
#myStreamsBox {
|
|
||||||
border-bottom: 1px solid var(--line-soft);
|
|
||||||
background: rgba(10, 22, 36, 0.62);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#allStreams {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#activeStreams,
|
|
||||||
#inactiveStreams {
|
|
||||||
max-height: 120px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inactiveStreams .tdKey {
|
|
||||||
color: #ff8f8f;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content {
|
#content {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -332,7 +95,7 @@ nav p {
|
|||||||
|
|
||||||
#content h3 {
|
#content h3 {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
color: #d4edff;
|
color: var(--text-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
#content-interaction {
|
#content-interaction {
|
||||||
@@ -353,162 +116,14 @@ nav p {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#box-wrapper {
|
/* Login, first-user, wizard and maintenance box */
|
||||||
width: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid var(--line-soft);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(10, 21, 34, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_table,
|
|
||||||
#mapping-detail-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_table .content_table_header {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 2;
|
|
||||||
background: linear-gradient(180deg, rgba(23, 46, 72, 0.98), rgba(16, 32, 50, 0.98));
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_table .content_table_header td {
|
|
||||||
color: #8fd8ff;
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
padding: 10px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_table tr {
|
|
||||||
border-left: 3px solid transparent;
|
|
||||||
border-bottom: 1px solid rgba(29, 55, 80, 0.72);
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_table tr:hover {
|
|
||||||
background-color: rgba(28, 53, 79, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_table tr[tabindex],
|
|
||||||
#content_table td[tabindex] {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_table tr[tabindex]:focus-visible,
|
|
||||||
#content_table td[tabindex]:focus-visible,
|
|
||||||
.keyboard-clickable:focus-visible {
|
|
||||||
outline: 2px solid #7de5ff;
|
|
||||||
outline-offset: -2px;
|
|
||||||
background-color: rgba(39, 73, 106, 0.56);
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_table td {
|
|
||||||
padding: 7px 8px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_table img {
|
|
||||||
display: block;
|
|
||||||
width: auto;
|
|
||||||
max-width: 34px;
|
|
||||||
max-height: 30px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_table input[type=text] {
|
|
||||||
min-width: 48px;
|
|
||||||
max-width: 76px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 6px 7px;
|
|
||||||
border-radius: 8px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_table input[type=checkbox] {
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_table .mapping-edit-button {
|
|
||||||
min-height: 32px;
|
|
||||||
min-width: 64px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 5px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_table tr.activeEPG {
|
|
||||||
border-left-color: #2ed486;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_table tr.notActiveEPG {
|
|
||||||
border-left-color: #ff6868;
|
|
||||||
}
|
|
||||||
|
|
||||||
#settings {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_settings h4 {
|
|
||||||
margin: 15px 0 8px;
|
|
||||||
color: #d6efff;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_settings table {
|
|
||||||
border: 1px solid var(--line-soft);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(10, 22, 35, 0.62);
|
|
||||||
border-collapse: separate;
|
|
||||||
border-spacing: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_settings td {
|
|
||||||
padding: 9px 8px;
|
|
||||||
border-bottom: 1px solid rgba(31, 57, 82, 0.62);
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_settings tr:last-child td {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_settings pre {
|
|
||||||
color: #9ab7d0;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_log {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#content_log pre {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(13, 31, 49, 0.72);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
word-break: break-word;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logScreen p {
|
|
||||||
white-space: pre;
|
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", "Consolas", monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
#box {
|
#box {
|
||||||
width: min(680px, calc(100% - 24px));
|
width: min(680px, calc(100% - 24px));
|
||||||
margin: 14px auto;
|
margin: 14px auto;
|
||||||
border-radius: var(--radius-l);
|
border-radius: var(--radius-l);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: linear-gradient(180deg, rgba(16, 35, 56, 0.95) 0%, rgba(11, 24, 38, 0.96) 100%);
|
background: linear-gradient(180deg, var(--panel-top) 0%, var(--panel-bottom) 100%);
|
||||||
box-shadow: var(--shadow-2);
|
box-shadow: var(--shadow-2);
|
||||||
min-height: 430px;
|
min-height: 430px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -523,14 +138,14 @@ nav p {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(16, 35, 56, 0.84), rgba(11, 24, 38, 0.9)),
|
linear-gradient(180deg, var(--logo-tint-top), var(--logo-tint-bottom)),
|
||||||
url("../img/logo_w_600x200.png") center no-repeat;
|
url("../img/logo_w_600x200.png") center no-repeat;
|
||||||
background-size: 300px auto;
|
background-size: 300px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#headline {
|
#headline {
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
background: rgba(10, 22, 37, 0.55);
|
background: var(--surface-0);
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,10 +175,6 @@ nav p {
|
|||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard {
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-screen {
|
.auth-screen {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -578,6 +189,8 @@ nav p {
|
|||||||
display: table-cell;
|
display: table-cell;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Breakpoints */
|
||||||
|
|
||||||
@media only screen and (max-width: 1300px) {
|
@media only screen and (max-width: 1300px) {
|
||||||
.dashboard-cards {
|
.dashboard-cards {
|
||||||
grid-template-columns: repeat(3, minmax(130px, 1fr));
|
grid-template-columns: repeat(3, minmax(130px, 1fr));
|
||||||
@@ -622,7 +235,7 @@ nav p {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: rgba(3, 9, 15, 0.48);
|
background: var(--overlay-soft);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -698,7 +311,7 @@ nav p {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid var(--line-soft);
|
border: 1px solid var(--line-soft);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(9, 22, 36, 0.92);
|
background: var(--surface-solid);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,7 +384,7 @@ nav p {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
background: rgba(8, 20, 33, 0.44);
|
background: var(--surface-0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#clientInfo tr {
|
#clientInfo tr {
|
||||||
@@ -791,16 +404,16 @@ nav p {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 10px 9px;
|
padding: 10px 9px;
|
||||||
background: rgba(17, 34, 53, 0.5);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#clientInfo .tdVal::before {
|
#clientInfo .tdVal::before {
|
||||||
content: attr(data-label);
|
content: attr(data-label);
|
||||||
color: #8bb9d7;
|
color: var(--text-soft);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.07em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-family: "Space Grotesk", "Avenir Next", "Trebuchet MS", sans-serif;
|
font-family: var(--font-sans);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,7 +442,7 @@ nav p {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
background: rgba(11, 24, 39, 0.86);
|
background: var(--surface-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#content_table td {
|
#content_table td {
|
||||||
@@ -841,12 +454,12 @@ nav p {
|
|||||||
grid-template-columns: minmax(92px, 36%) 1fr;
|
grid-template-columns: minmax(92px, 36%) 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: rgba(17, 34, 53, 0.5);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#content_table td::before {
|
#content_table td::before {
|
||||||
content: attr(data-label);
|
content: attr(data-label);
|
||||||
color: #8bb9d7;
|
color: var(--text-soft);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -967,6 +580,84 @@ nav p {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 619px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.46rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-custom,
|
||||||
|
#mapping-detail,
|
||||||
|
#user-detail,
|
||||||
|
#file-detail {
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
border: 0;
|
||||||
|
padding: 10px 10px calc(14px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-custom table {
|
||||||
|
border-spacing: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-custom tr {
|
||||||
|
display: block;
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 8px 10px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-custom td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-custom td.left {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#popup-interaction {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 5;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
background: linear-gradient(180deg, var(--sticky-fade-from) 0%, var(--sticky-fade-to) 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interaction,
|
||||||
|
#interaction {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interaction input[type=button],
|
||||||
|
.interaction input[type=submit],
|
||||||
|
#popup-interaction input[type=button] {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-title h3 {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-close {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 430px) {
|
@media only screen and (max-width: 430px) {
|
||||||
#clientInfo {
|
#clientInfo {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
/*
|
||||||
|
* tokens.css - design tokens (colours, spacing, radii, fonts, shadows).
|
||||||
|
*
|
||||||
|
* Load order: tokens.css, base.css, components.css, layout.css.
|
||||||
|
*
|
||||||
|
* The dark palette is the default. A light palette is applied when the user
|
||||||
|
* agent prefers a light colour scheme, and either palette can be forced with
|
||||||
|
* <html data-theme="dark"> or <html data-theme="light">, which always wins
|
||||||
|
* over the media query. Only colour tokens change between themes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
/* Fonts */
|
||||||
|
--font-sans: "Space Grotesk", "Avenir Next", "Trebuchet MS", sans-serif;
|
||||||
|
--font-mono: "IBM Plex Mono", "SFMono-Regular", "Consolas", monospace;
|
||||||
|
|
||||||
|
/* Radii and shadows */
|
||||||
|
--radius-s: 10px;
|
||||||
|
--radius-m: 14px;
|
||||||
|
--radius-l: 18px;
|
||||||
|
--shadow-1: 0 20px 45px rgba(0, 0, 0, 0.32);
|
||||||
|
--shadow-2: 0 12px 30px rgba(0, 0, 0, 0.28);
|
||||||
|
|
||||||
|
/* Page background */
|
||||||
|
--bg-0: #07111d;
|
||||||
|
--bg-1: #0c1a2b;
|
||||||
|
--bg-2: #12233a;
|
||||||
|
--bg-deep: #060d17;
|
||||||
|
--bg-glow-1: rgba(64, 132, 183, 0.22);
|
||||||
|
--bg-glow-2: rgba(18, 185, 255, 0.16);
|
||||||
|
|
||||||
|
/* Panels and surfaces (translucent tints layered over the page) */
|
||||||
|
--panel-top: rgba(17, 34, 56, 0.97);
|
||||||
|
--panel-bottom: rgba(12, 27, 44, 0.97);
|
||||||
|
--panel-glow: rgba(53, 210, 255, 0.12);
|
||||||
|
--surface-0: rgba(8, 20, 33, 0.54);
|
||||||
|
--surface-1: rgba(10, 22, 36, 0.72);
|
||||||
|
--surface-2: rgba(13, 31, 49, 0.8);
|
||||||
|
--surface-3: rgba(17, 35, 56, 0.84);
|
||||||
|
--surface-solid: rgba(10, 22, 36, 0.92);
|
||||||
|
--surface-raised: rgba(14, 32, 50, 0.85);
|
||||||
|
--surface-input: rgba(10, 22, 36, 0.78);
|
||||||
|
--surface-input-focus: rgba(9, 25, 40, 0.95);
|
||||||
|
--logo-tint-top: rgba(26, 49, 73, 0.55);
|
||||||
|
--logo-tint-bottom: rgba(13, 28, 45, 0.6);
|
||||||
|
--table-head-top: rgba(23, 46, 72, 0.98);
|
||||||
|
--table-head-bottom: rgba(16, 32, 50, 0.98);
|
||||||
|
--row-line: rgba(29, 55, 80, 0.72);
|
||||||
|
--row-hover: rgba(28, 53, 79, 0.5);
|
||||||
|
--row-focus: rgba(39, 73, 106, 0.56);
|
||||||
|
--overlay: rgba(3, 9, 16, 0.72);
|
||||||
|
--overlay-soft: rgba(3, 9, 15, 0.48);
|
||||||
|
--sticky-fade-from: rgba(12, 27, 44, 0.1);
|
||||||
|
--sticky-fade-to: rgba(12, 27, 44, 0.96);
|
||||||
|
--scroll-track: rgba(7, 17, 29, 0.65);
|
||||||
|
--scroll-thumb-a: #2d597f;
|
||||||
|
--scroll-thumb-b: #1e4060;
|
||||||
|
--scroll-thumb-hover-a: #3678ac;
|
||||||
|
--scroll-thumb-hover-b: #20537c;
|
||||||
|
|
||||||
|
/* Lines */
|
||||||
|
--line: #274462;
|
||||||
|
--line-soft: #1b334d;
|
||||||
|
--line-strong: #3a5877;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text: #e9f5ff;
|
||||||
|
--text-heading: #d6efff;
|
||||||
|
--text-muted: #b7cee1;
|
||||||
|
--text-soft: #8fb3cf;
|
||||||
|
--text-placeholder: #7e9ab4;
|
||||||
|
--text-on-accent: #03101a;
|
||||||
|
--text-on-ok: #052219;
|
||||||
|
--text-on-error: #ffe5e5;
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--accent: #35d2ff;
|
||||||
|
--accent-strong: #12b9ff;
|
||||||
|
--accent-bright: #67e8ff;
|
||||||
|
--accent-hover: #8de7ff;
|
||||||
|
--accent-text: #8fd8ff;
|
||||||
|
--accent-text-hover: #d7f4ff;
|
||||||
|
--accent-soft: rgba(53, 210, 255, 0.2);
|
||||||
|
--accent-ring: rgba(53, 210, 255, 0.17);
|
||||||
|
--accent-border: rgba(103, 232, 255, 0.55);
|
||||||
|
--accent-shadow: rgba(18, 185, 255, 0.3);
|
||||||
|
--accent-shadow-strong: rgba(18, 185, 255, 0.36);
|
||||||
|
--accent-gradient: linear-gradient(135deg, var(--accent-bright) 0%, var(--accent-strong) 100%);
|
||||||
|
--link-underline: rgba(143, 220, 255, 0.6);
|
||||||
|
--focus-ring: #7de5ff;
|
||||||
|
--focus-ring-soft: #91eaff;
|
||||||
|
--checkbox-checked: #39d6ff;
|
||||||
|
--checkbox-checked-border: #89ebff;
|
||||||
|
--loader: #58ddff;
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--ok: #2fd18a;
|
||||||
|
--ok-bright: #92f0c0;
|
||||||
|
--ok-mid: #4fce93;
|
||||||
|
--ok-border: #9ff0c8;
|
||||||
|
--ok-soft: rgba(67, 191, 130, 0.48);
|
||||||
|
--ok-surface-top: rgba(20, 54, 44, 0.9);
|
||||||
|
--ok-surface-bottom: rgba(12, 38, 31, 0.95);
|
||||||
|
--warn: #ffc15a;
|
||||||
|
--warn-soft: rgba(255, 193, 90, 0.5);
|
||||||
|
--warn-surface-top: rgba(68, 50, 20, 0.86);
|
||||||
|
--warn-surface-bottom: rgba(44, 32, 14, 0.96);
|
||||||
|
--error: #ff6f6f;
|
||||||
|
--error-text: #ff8d8d;
|
||||||
|
--error-text-soft: #ffd8d8;
|
||||||
|
--error-bright: #ff7f7f;
|
||||||
|
--error-strong: #e94343;
|
||||||
|
--error-border: #ff9e9e;
|
||||||
|
--error-soft: rgba(255, 111, 111, 0.45);
|
||||||
|
--error-surface: rgba(87, 22, 22, 0.45);
|
||||||
|
--error-surface-top: rgba(73, 26, 26, 0.84);
|
||||||
|
--error-surface-bottom: rgba(47, 19, 19, 0.96);
|
||||||
|
--error-shadow: rgba(233, 67, 67, 0.35);
|
||||||
|
--info: #87b0d1;
|
||||||
|
--debug: #d687ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Light palette. Only tokens that differ from the dark palette are listed.
|
||||||
|
* The same block appears twice on purpose: once for the media query (skipped
|
||||||
|
* when dark is forced with data-theme="dark") and once for the explicit
|
||||||
|
* data-theme="light" override. Keep the two copies identical.
|
||||||
|
*/
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root:not([data-theme="dark"]) {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--bg-0: #e9eff5;
|
||||||
|
--bg-1: #f5f8fb;
|
||||||
|
--bg-2: #dfe7ef;
|
||||||
|
--bg-deep: #dfe7ef;
|
||||||
|
--bg-glow-1: rgba(64, 132, 183, 0.14);
|
||||||
|
--bg-glow-2: rgba(18, 185, 255, 0.12);
|
||||||
|
|
||||||
|
--panel-top: rgba(255, 255, 255, 0.97);
|
||||||
|
--panel-bottom: rgba(246, 249, 252, 0.97);
|
||||||
|
--panel-glow: rgba(18, 185, 255, 0.08);
|
||||||
|
--surface-0: rgba(255, 255, 255, 0.55);
|
||||||
|
--surface-1: rgba(255, 255, 255, 0.75);
|
||||||
|
--surface-2: rgba(236, 243, 249, 0.85);
|
||||||
|
--surface-3: rgba(232, 240, 247, 0.9);
|
||||||
|
--surface-solid: rgba(255, 255, 255, 0.94);
|
||||||
|
--surface-raised: rgba(228, 236, 244, 0.9);
|
||||||
|
--surface-input: rgba(255, 255, 255, 0.85);
|
||||||
|
--surface-input-focus: rgba(255, 255, 255, 1);
|
||||||
|
--logo-tint-top: rgba(15, 31, 48, 0.96);
|
||||||
|
--logo-tint-bottom: rgba(11, 23, 39, 0.98);
|
||||||
|
--table-head-top: rgba(232, 240, 248, 0.98);
|
||||||
|
--table-head-bottom: rgba(222, 232, 243, 0.98);
|
||||||
|
--row-line: rgba(100, 130, 160, 0.3);
|
||||||
|
--row-hover: rgba(0, 90, 150, 0.07);
|
||||||
|
--row-focus: rgba(0, 90, 150, 0.14);
|
||||||
|
--overlay: rgba(20, 35, 52, 0.5);
|
||||||
|
--overlay-soft: rgba(20, 35, 52, 0.35);
|
||||||
|
--sticky-fade-from: rgba(246, 249, 252, 0.1);
|
||||||
|
--sticky-fade-to: rgba(246, 249, 252, 0.96);
|
||||||
|
--scroll-track: rgba(200, 214, 228, 0.6);
|
||||||
|
--scroll-thumb-a: #9fb6cb;
|
||||||
|
--scroll-thumb-b: #7f9bb3;
|
||||||
|
--scroll-thumb-hover-a: #7f9bb3;
|
||||||
|
--scroll-thumb-hover-b: #61819c;
|
||||||
|
|
||||||
|
--line: #b9cadb;
|
||||||
|
--line-soft: #d3dfea;
|
||||||
|
--line-strong: #9fb4c8;
|
||||||
|
|
||||||
|
--text: #142434;
|
||||||
|
--text-heading: #0f2033;
|
||||||
|
--text-muted: #4a6379;
|
||||||
|
--text-soft: #526a80;
|
||||||
|
--text-placeholder: #58708a;
|
||||||
|
--text-on-error: #4a0c0c;
|
||||||
|
|
||||||
|
--accent: #0069a8;
|
||||||
|
--accent-hover: #00517f;
|
||||||
|
--accent-text: #0b6a95;
|
||||||
|
--accent-text-hover: #003a5c;
|
||||||
|
--accent-soft: rgba(0, 105, 168, 0.2);
|
||||||
|
--accent-ring: rgba(0, 105, 168, 0.18);
|
||||||
|
--accent-border: rgba(0, 105, 168, 0.5);
|
||||||
|
--accent-shadow: rgba(0, 105, 168, 0.22);
|
||||||
|
--accent-shadow-strong: rgba(0, 105, 168, 0.3);
|
||||||
|
--link-underline: rgba(11, 106, 149, 0.5);
|
||||||
|
--focus-ring: #0a7fc0;
|
||||||
|
--focus-ring-soft: #0a7fc0;
|
||||||
|
--checkbox-checked: #12b9ff;
|
||||||
|
--checkbox-checked-border: #0a7fc0;
|
||||||
|
--loader: #0a7fc0;
|
||||||
|
|
||||||
|
--ok: #12734a;
|
||||||
|
--ok-bright: #a9efcc;
|
||||||
|
--ok-mid: #5fd39b;
|
||||||
|
--ok-border: #4fb884;
|
||||||
|
--ok-soft: rgba(18, 115, 74, 0.45);
|
||||||
|
--ok-surface-top: rgba(226, 246, 235, 0.95);
|
||||||
|
--ok-surface-bottom: rgba(208, 238, 222, 0.98);
|
||||||
|
--warn: #7f5300;
|
||||||
|
--warn-soft: rgba(183, 121, 31, 0.5);
|
||||||
|
--warn-surface-top: rgba(253, 243, 224, 0.95);
|
||||||
|
--warn-surface-bottom: rgba(248, 232, 200, 0.98);
|
||||||
|
--error: #c62828;
|
||||||
|
--error-text: #b02020;
|
||||||
|
--error-text-soft: #8a1a1a;
|
||||||
|
--error-bright: #f6a5a5;
|
||||||
|
--error-strong: #e07575;
|
||||||
|
--error-border: #d95c5c;
|
||||||
|
--error-soft: rgba(198, 40, 40, 0.45);
|
||||||
|
--error-surface: rgba(255, 220, 220, 0.6);
|
||||||
|
--error-surface-top: rgba(253, 232, 232, 0.95);
|
||||||
|
--error-surface-bottom: rgba(248, 214, 214, 0.98);
|
||||||
|
--error-shadow: rgba(198, 40, 40, 0.25);
|
||||||
|
--info: #3f6785;
|
||||||
|
--debug: #7a35b5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--bg-0: #e9eff5;
|
||||||
|
--bg-1: #f5f8fb;
|
||||||
|
--bg-2: #dfe7ef;
|
||||||
|
--bg-deep: #dfe7ef;
|
||||||
|
--bg-glow-1: rgba(64, 132, 183, 0.14);
|
||||||
|
--bg-glow-2: rgba(18, 185, 255, 0.12);
|
||||||
|
|
||||||
|
--panel-top: rgba(255, 255, 255, 0.97);
|
||||||
|
--panel-bottom: rgba(246, 249, 252, 0.97);
|
||||||
|
--panel-glow: rgba(18, 185, 255, 0.08);
|
||||||
|
--surface-0: rgba(255, 255, 255, 0.55);
|
||||||
|
--surface-1: rgba(255, 255, 255, 0.75);
|
||||||
|
--surface-2: rgba(236, 243, 249, 0.85);
|
||||||
|
--surface-3: rgba(232, 240, 247, 0.9);
|
||||||
|
--surface-solid: rgba(255, 255, 255, 0.94);
|
||||||
|
--surface-raised: rgba(228, 236, 244, 0.9);
|
||||||
|
--surface-input: rgba(255, 255, 255, 0.85);
|
||||||
|
--surface-input-focus: rgba(255, 255, 255, 1);
|
||||||
|
--logo-tint-top: rgba(15, 31, 48, 0.96);
|
||||||
|
--logo-tint-bottom: rgba(11, 23, 39, 0.98);
|
||||||
|
--table-head-top: rgba(232, 240, 248, 0.98);
|
||||||
|
--table-head-bottom: rgba(222, 232, 243, 0.98);
|
||||||
|
--row-line: rgba(100, 130, 160, 0.3);
|
||||||
|
--row-hover: rgba(0, 90, 150, 0.07);
|
||||||
|
--row-focus: rgba(0, 90, 150, 0.14);
|
||||||
|
--overlay: rgba(20, 35, 52, 0.5);
|
||||||
|
--overlay-soft: rgba(20, 35, 52, 0.35);
|
||||||
|
--sticky-fade-from: rgba(246, 249, 252, 0.1);
|
||||||
|
--sticky-fade-to: rgba(246, 249, 252, 0.96);
|
||||||
|
--scroll-track: rgba(200, 214, 228, 0.6);
|
||||||
|
--scroll-thumb-a: #9fb6cb;
|
||||||
|
--scroll-thumb-b: #7f9bb3;
|
||||||
|
--scroll-thumb-hover-a: #7f9bb3;
|
||||||
|
--scroll-thumb-hover-b: #61819c;
|
||||||
|
|
||||||
|
--line: #b9cadb;
|
||||||
|
--line-soft: #d3dfea;
|
||||||
|
--line-strong: #9fb4c8;
|
||||||
|
|
||||||
|
--text: #142434;
|
||||||
|
--text-heading: #0f2033;
|
||||||
|
--text-muted: #4a6379;
|
||||||
|
--text-soft: #526a80;
|
||||||
|
--text-placeholder: #58708a;
|
||||||
|
--text-on-error: #4a0c0c;
|
||||||
|
|
||||||
|
--accent: #0069a8;
|
||||||
|
--accent-hover: #00517f;
|
||||||
|
--accent-text: #0b6a95;
|
||||||
|
--accent-text-hover: #003a5c;
|
||||||
|
--accent-soft: rgba(0, 105, 168, 0.2);
|
||||||
|
--accent-ring: rgba(0, 105, 168, 0.18);
|
||||||
|
--accent-border: rgba(0, 105, 168, 0.5);
|
||||||
|
--accent-shadow: rgba(0, 105, 168, 0.22);
|
||||||
|
--accent-shadow-strong: rgba(0, 105, 168, 0.3);
|
||||||
|
--link-underline: rgba(11, 106, 149, 0.5);
|
||||||
|
--focus-ring: #0a7fc0;
|
||||||
|
--focus-ring-soft: #0a7fc0;
|
||||||
|
--checkbox-checked: #12b9ff;
|
||||||
|
--checkbox-checked-border: #0a7fc0;
|
||||||
|
--loader: #0a7fc0;
|
||||||
|
|
||||||
|
--ok: #12734a;
|
||||||
|
--ok-bright: #a9efcc;
|
||||||
|
--ok-mid: #5fd39b;
|
||||||
|
--ok-border: #4fb884;
|
||||||
|
--ok-soft: rgba(18, 115, 74, 0.45);
|
||||||
|
--ok-surface-top: rgba(226, 246, 235, 0.95);
|
||||||
|
--ok-surface-bottom: rgba(208, 238, 222, 0.98);
|
||||||
|
--warn: #7f5300;
|
||||||
|
--warn-soft: rgba(183, 121, 31, 0.5);
|
||||||
|
--warn-surface-top: rgba(253, 243, 224, 0.95);
|
||||||
|
--warn-surface-bottom: rgba(248, 232, 200, 0.98);
|
||||||
|
--error: #c62828;
|
||||||
|
--error-text: #b02020;
|
||||||
|
--error-text-soft: #8a1a1a;
|
||||||
|
--error-bright: #f6a5a5;
|
||||||
|
--error-strong: #e07575;
|
||||||
|
--error-border: #d95c5c;
|
||||||
|
--error-soft: rgba(198, 40, 40, 0.45);
|
||||||
|
--error-surface: rgba(255, 220, 220, 0.6);
|
||||||
|
--error-surface-top: rgba(253, 232, 232, 0.95);
|
||||||
|
--error-surface-bottom: rgba(248, 214, 214, 0.98);
|
||||||
|
--error-shadow: rgba(198, 40, 40, 0.25);
|
||||||
|
--info: #3f6785;
|
||||||
|
--debug: #7a35b5;
|
||||||
|
}
|
||||||
+7
-5
@@ -1,11 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>xTeVe</title>
|
<title>xTeVe</title>
|
||||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
<link rel="stylesheet" href="css/tokens.css">
|
||||||
<link rel="stylesheet" href="css/base.css" type="text/css">
|
<link rel="stylesheet" href="css/base.css">
|
||||||
|
<link rel="stylesheet" href="css/components.css">
|
||||||
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
@@ -37,7 +39,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<aside id="menu-wrapper" class="layout-left" aria-label="Sidebar menu">
|
<aside id="menu-wrapper" class="layout-left" aria-label="Sidebar menu">
|
||||||
<div id= "branch"></div>
|
<div id="branch"></div>
|
||||||
<div id="logo"></div>
|
<div id="logo"></div>
|
||||||
<nav id="main-menu" role="menubar" aria-label="Main navigation"></nav>
|
<nav id="main-menu" role="menubar" aria-label="Main navigation"></nav>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -45,7 +47,7 @@
|
|||||||
<main id="shell-main" class="layout-right">
|
<main id="shell-main" class="layout-right">
|
||||||
<header id="shell-header">
|
<header id="shell-header">
|
||||||
<button id="menu-toggle" type="button" aria-expanded="false" aria-controls="menu-wrapper" aria-label="Toggle navigation menu">Menu</button>
|
<button id="menu-toggle" type="button" aria-expanded="false" aria-controls="menu-wrapper" aria-label="Toggle navigation menu">Menu</button>
|
||||||
<h2 id="shell-title">xTeVe Control Panel</h2>
|
<h1 id="shell-title">xTeVe Control Panel</h1>
|
||||||
<p id="connection-indicator" class="status-idle" role="status" aria-live="polite" aria-atomic="true">Connecting...</p>
|
<p id="connection-indicator" class="status-idle" role="status" aria-live="polite" aria-atomic="true">Connecting...</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
+1072
-1244
File diff suppressed because it is too large.
Load diff
+6
-4
@@ -1,11 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>xTeVe</title>
|
<title>xTeVe</title>
|
||||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
<link rel="stylesheet" href="css/tokens.css">
|
||||||
<link rel="stylesheet" href="css/base.css" type="text/css">
|
<link rel="stylesheet" href="css/base.css">
|
||||||
|
<link rel="stylesheet" href="css/components.css">
|
||||||
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@
|
|||||||
|
|
||||||
<div id="header" class="imgCenter"></div>
|
<div id="header" class="imgCenter"></div>
|
||||||
|
|
||||||
<main id="box" role="main" aria-labelledby="head-text">
|
<main id="box" aria-labelledby="head-text">
|
||||||
|
|
||||||
<div id="headline">
|
<div id="headline">
|
||||||
<h1 id="head-text" class="center">Login</h1>
|
<h1 id="head-text" class="center">Login</h1>
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>xTeVe</title>
|
<title>xTeVe</title>
|
||||||
<link rel="stylesheet" href="css/screen.css" type="text/css">
|
<link rel="stylesheet" href="css/tokens.css">
|
||||||
<link rel="stylesheet" href="css/base.css" type="text/css">
|
<link rel="stylesheet" href="css/base.css">
|
||||||
|
<link rel="stylesheet" href="css/components.css">
|
||||||
|
<link rel="stylesheet" href="css/layout.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="auth-screen">
|
<body class="auth-screen">
|
||||||
|
|
||||||
<div id="header" class="imgCenter"></div>
|
<div id="header" class="imgCenter"></div>
|
||||||
|
|
||||||
<div id="box">
|
<main id="box" aria-labelledby="head-text">
|
||||||
|
|
||||||
<div id="headline">
|
<div id="headline">
|
||||||
<h1 id="head-text" class="center">Maintenance</h1>
|
<h1 id="head-text" class="center">Maintenance</h1>
|
||||||
@@ -24,7 +26,7 @@
|
|||||||
|
|
||||||
<div id="box-footer"></div>
|
<div id="box-footer"></div>
|
||||||
|
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Generated
+532
@@ -6,9 +6,466 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "xteve-webui",
|
"name": "xteve-webui",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"jsdom": "30.1.1",
|
||||||
"typescript": "5.9.3"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -22,6 +479,81 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.17"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-1
@@ -4,9 +4,11 @@
|
|||||||
"description": "Build tooling for the xTeVe web interface (TypeScript -> html/js/app.js)",
|
"description": "Build tooling for the xTeVe web interface (TypeScript -> html/js/app.js)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p ts/tsconfig.json",
|
"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": {
|
"devDependencies": {
|
||||||
|
"jsdom": "30.1.1",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
-5
@@ -63,7 +63,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
|
|||||||
|
|
||||||
if isNew {
|
if isNew {
|
||||||
|
|
||||||
switch Settings.Buffer {
|
switch bufferModeFor(playlistID) {
|
||||||
|
|
||||||
case "xteve":
|
case "xteve":
|
||||||
go connectToStreamingServer(streamID, playlistID)
|
go connectToStreamingServer(streamID, playlistID)
|
||||||
@@ -86,7 +86,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.Err != nil {
|
if s.Err != nil {
|
||||||
ShowError(s.Err, 0)
|
// Already logged by the downloader that reported it.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.Status {
|
if s.Status {
|
||||||
@@ -127,7 +127,7 @@ func bufferingStream(playlistID, streamingURL, channelName string, w http.Respon
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.Err != nil {
|
if s.Err != nil {
|
||||||
ShowError(s.Err, 0)
|
// Already logged by the downloader that reported it.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1074,7 +1074,7 @@ func thirdPartyBuffer(streamID int, playlistID string) {
|
|||||||
|
|
||||||
var path, options, bufferType string
|
var path, options, bufferType string
|
||||||
|
|
||||||
switch Settings.Buffer {
|
switch bufferModeFor(playlistID) {
|
||||||
|
|
||||||
case "ffmpeg":
|
case "ffmpeg":
|
||||||
path, options, bufferType = Settings.FFmpegPath, Settings.FFmpegOptions, "FFMPEG"
|
path, options, bufferType = Settings.FFmpegPath, Settings.FFmpegOptions, "FFMPEG"
|
||||||
@@ -1259,7 +1259,7 @@ func thirdPartyBuffer(streamID int, playlistID string) {
|
|||||||
|
|
||||||
func getTuner(id, playlistType string) (tuner int) {
|
func getTuner(id, playlistType string) (tuner int) {
|
||||||
|
|
||||||
switch Settings.Buffer {
|
switch bufferModeFor(id) {
|
||||||
|
|
||||||
case "-":
|
case "-":
|
||||||
tuner = Settings.Tuner
|
tuner = Settings.Tuner
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package src
|
||||||
|
|
||||||
|
// bufferModeFor : buffer used for a playlist. A playlist can override the
|
||||||
|
// global "buffer" setting with its own "buffer" parameter ("-", "xteve",
|
||||||
|
// "ffmpeg" or "vlc"); anything else means "use the global setting".
|
||||||
|
func bufferModeFor(playlistID string) string {
|
||||||
|
|
||||||
|
if len(playlistID) > 0 {
|
||||||
|
|
||||||
|
var playlistType string
|
||||||
|
switch playlistID[0:1] {
|
||||||
|
case "M":
|
||||||
|
playlistType = "m3u"
|
||||||
|
case "H":
|
||||||
|
playlistType = "hdhr"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := getProviderParameter(playlistID, playlistType, "buffer"); v {
|
||||||
|
case "-", "xteve", "ffmpeg", "vlc":
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return Settings.Buffer
|
||||||
|
}
|
||||||
+28
@@ -534,6 +534,23 @@ func saveFilter(request RequestStruct) (settings SettingsStruct, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regular expressions are checked before anything is saved.
|
||||||
|
var filterType, _ = data.(map[string]any)["type"].(string)
|
||||||
|
if filterType == "" {
|
||||||
|
if old, ok := filterMap[dataID].(map[string]any); ok {
|
||||||
|
filterType, _ = old["type"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filterType == "regex-filter" {
|
||||||
|
if reErr := validateRegexFilter(filter); reErr != nil {
|
||||||
|
err = fmt.Errorf("invalid regular expression: %v", reErr)
|
||||||
|
if newFilter {
|
||||||
|
delete(filterMap, dataID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if oldData, ok := filterMap[dataID].(map[string]any); ok {
|
if oldData, ok := filterMap[dataID].(map[string]any); ok {
|
||||||
@@ -839,6 +856,17 @@ func createFilterRules() (err error) {
|
|||||||
|
|
||||||
Data.Filter = append(Data.Filter, dataFilter)
|
Data.Filter = append(Data.Filter, dataFilter)
|
||||||
|
|
||||||
|
case "regex-filter":
|
||||||
|
if err = validateRegexFilter(filter.Filter); err != nil {
|
||||||
|
return fmt.Errorf("filter %q: invalid regular expression: %v", filter.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dataFilter.CaseSensitive = filter.CaseSensitive
|
||||||
|
dataFilter.Rule = filter.Filter
|
||||||
|
dataFilter.Type = filter.Type
|
||||||
|
|
||||||
|
Data.Filter = append(Data.Filter, dataFilter)
|
||||||
|
|
||||||
case "group-title":
|
case "group-title":
|
||||||
if len(filter.Include) > 0 {
|
if len(filter.Include) > 0 {
|
||||||
include = fmt.Sprintf(" {%s}", filter.Include)
|
include = fmt.Sprintf(" {%s}", filter.Include)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package src
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func streamFor(name, group string) map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"name": name,
|
||||||
|
"group-title": group,
|
||||||
|
"_values": "tvg-id=\"" + name + "\" group-title=\"" + group + "\"," + name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegexFilter(t *testing.T) {
|
||||||
|
Data.Filter = []Filter{{Type: "regex-filter", Rule: `^(BBC|ITV) \d+$`, CaseSensitive: false}}
|
||||||
|
regexFilterCache.Range(func(k, v any) bool { regexFilterCache.Delete(k); return true })
|
||||||
|
|
||||||
|
cases := map[string]bool{
|
||||||
|
"BBC 1": true,
|
||||||
|
"itv 2": true, // case-insensitive by default
|
||||||
|
"BBC News": false,
|
||||||
|
"Sky One": false,
|
||||||
|
"BBC 1 HD": false,
|
||||||
|
"Radio BBC": false,
|
||||||
|
}
|
||||||
|
// Group title is matched too.
|
||||||
|
Data.Filter = append(Data.Filter, Filter{Type: "regex-filter", Rule: `^Sports$`})
|
||||||
|
cases["Sports"] = false // name "Sports" is only in group below
|
||||||
|
if !filterThisStream(streamFor("Golf", "Sports")) {
|
||||||
|
t.Error("regex should match the group title")
|
||||||
|
}
|
||||||
|
delete(cases, "Sports")
|
||||||
|
for name, want := range cases {
|
||||||
|
if got := filterThisStream(streamFor(name, "UK")); got != want {
|
||||||
|
t.Errorf("%q: got %v, want %v", name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Data.Filter = []Filter{{Type: "regex-filter", Rule: `^bbc`, CaseSensitive: true}}
|
||||||
|
if filterThisStream(streamFor("BBC 1", "UK")) {
|
||||||
|
t.Error("case-sensitive rule must not match different case")
|
||||||
|
}
|
||||||
|
|
||||||
|
Data.Filter = []Filter{{Type: "regex-filter", Rule: `(`, CaseSensitive: false}}
|
||||||
|
if filterThisStream(streamFor("BBC 1", "UK")) {
|
||||||
|
t.Error("invalid pattern must never match")
|
||||||
|
}
|
||||||
|
if validateRegexFilter(`(`) == nil {
|
||||||
|
t.Error("validateRegexFilter accepted an invalid pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
Data.Filter = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBufferModeFor(t *testing.T) {
|
||||||
|
Settings.Buffer = "xteve"
|
||||||
|
Settings.Files.M3U = map[string]any{
|
||||||
|
"M-a": map[string]any{"name": "A", "buffer": "ffmpeg"},
|
||||||
|
"M-b": map[string]any{"name": "B", "buffer": "default"},
|
||||||
|
"M-c": map[string]any{"name": "C"},
|
||||||
|
}
|
||||||
|
Settings.Files.HDHR = map[string]any{
|
||||||
|
"H-d": map[string]any{"name": "D", "buffer": "-"},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { Settings.Buffer = "-" })
|
||||||
|
|
||||||
|
cases := map[string]string{"M-a": "ffmpeg", "M-b": "xteve", "M-c": "xteve", "H-d": "-", "M-missing": "xteve", "": "xteve"}
|
||||||
|
for id, want := range cases {
|
||||||
|
if got := bufferModeFor(id); got != want {
|
||||||
|
t.Errorf("%q: got %q, want %q", id, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-6
@@ -8,6 +8,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
m3u "xteve/src/internal/m3u-parser"
|
m3u "xteve/src/internal/m3u-parser"
|
||||||
)
|
)
|
||||||
@@ -34,12 +35,47 @@ func parsePlaylist(filename, fileType string) (channels []any, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Streams filtern
|
// Streams filtern
|
||||||
|
// Patterns for the {include} and !{exclude} parts of a filter rule.
|
||||||
|
var filterIncludeRe = regexp.MustCompile(`[{]+[^.]+[}]`)
|
||||||
|
var filterExcludeRe = regexp.MustCompile(`!+[{]+[^.]+[}]`)
|
||||||
|
|
||||||
|
// regexFilterCache : compiled "regex-filter" rules, keyed by rule and case mode.
|
||||||
|
var regexFilterCache sync.Map
|
||||||
|
|
||||||
|
// compileRegexFilter : compiles (and caches) a regex-filter rule. Case
|
||||||
|
// insensitive rules get the (?i) flag. Returns nil for an invalid pattern.
|
||||||
|
func compileRegexFilter(rule string, caseSensitive bool) *regexp.Regexp {
|
||||||
|
|
||||||
|
var key = fmt.Sprintf("%t:%s", caseSensitive, rule)
|
||||||
|
|
||||||
|
if v, ok := regexFilterCache.Load(key); ok {
|
||||||
|
return v.(*regexp.Regexp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pattern = rule
|
||||||
|
if !caseSensitive {
|
||||||
|
pattern = "(?i)" + rule
|
||||||
|
}
|
||||||
|
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
regexFilterCache.Store(key, re)
|
||||||
|
return re
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRegexFilter : error for an invalid regex-filter rule.
|
||||||
|
func validateRegexFilter(rule string) error {
|
||||||
|
_, err := regexp.Compile(rule)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func filterThisStream(s any) (status bool) {
|
func filterThisStream(s any) (status bool) {
|
||||||
|
|
||||||
status = false
|
status = false
|
||||||
var stream = s.(map[string]string)
|
var stream = s.(map[string]string)
|
||||||
var regexpYES = `[{]+[^.]+[}]`
|
|
||||||
var regexpNO = `!+[{]+[^.]+[}]`
|
|
||||||
|
|
||||||
for _, filter := range Data.Filter {
|
for _, filter := range Data.Filter {
|
||||||
|
|
||||||
@@ -53,6 +89,17 @@ func filterThisStream(s any) (status bool) {
|
|||||||
|
|
||||||
var streamValues = strings.Replace(stream["_values"], "\r", "", -1)
|
var streamValues = strings.Replace(stream["_values"], "\r", "", -1)
|
||||||
|
|
||||||
|
// Regular expression, tried against the channel name, then the group
|
||||||
|
// title, then the raw attribute line (so anchors work on the name).
|
||||||
|
if filter.Type == "regex-filter" {
|
||||||
|
if re := compileRegexFilter(filter.Rule, filter.CaseSensitive); re != nil {
|
||||||
|
if re.MatchString(stream["name"]) || re.MatchString(stream["group-title"]) || re.MatchString(streamValues) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if v, ok := stream["group-title"]; ok {
|
if v, ok := stream["group-title"]; ok {
|
||||||
group = v
|
group = v
|
||||||
}
|
}
|
||||||
@@ -62,8 +109,7 @@ func filterThisStream(s any) (status bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unerwünschte Streams !{DEU}
|
// Unerwünschte Streams !{DEU}
|
||||||
r := regexp.MustCompile(regexpNO)
|
val := filterExcludeRe.FindStringSubmatch(filter.Rule)
|
||||||
val := r.FindStringSubmatch(filter.Rule)
|
|
||||||
|
|
||||||
if len(val) == 1 {
|
if len(val) == 1 {
|
||||||
|
|
||||||
@@ -74,8 +120,7 @@ func filterThisStream(s any) (status bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Muss zusätzlich erfüllt sein {DEU}
|
// Muss zusätzlich erfüllt sein {DEU}
|
||||||
r = regexp.MustCompile(regexpYES)
|
val = filterIncludeRe.FindStringSubmatch(filter.Rule)
|
||||||
val = r.FindStringSubmatch(filter.Rule)
|
|
||||||
|
|
||||||
if len(val) == 1 {
|
if len(val) == 1 {
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -109,7 +109,7 @@ func showInfo(str string) {
|
|||||||
|
|
||||||
printLogOnScreen(logMsg, "info")
|
printLogOnScreen(logMsg, "info")
|
||||||
|
|
||||||
logAppend(strings.Replace(logMsg, " ", " ", -1))
|
logAppend(logMsg)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ func showDebug(str string, level int) {
|
|||||||
|
|
||||||
printLogOnScreen(logMsg, "debug")
|
printLogOnScreen(logMsg, "debug")
|
||||||
|
|
||||||
logAppend(strings.Replace(logMsg, " ", " ", -1))
|
logAppend(logMsg)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,3 +106,28 @@ func TestShowHighlightWithoutColon(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The web UI renders log lines as text inside <pre>, so the server must store
|
||||||
|
// plain spaces, not HTML entities.
|
||||||
|
func TestLogLinesContainNoHTMLEntities(t *testing.T) {
|
||||||
|
var oldLimit = Settings.LogEntriesRAM
|
||||||
|
Settings.LogEntriesRAM = 500
|
||||||
|
t.Cleanup(func() { Settings.LogEntriesRAM = oldLimit })
|
||||||
|
|
||||||
|
resetWebScreenLog()
|
||||||
|
showInfo("Version:2.2.0 Build: 0201")
|
||||||
|
showDebug("Buffer Status:some debug text", 0)
|
||||||
|
|
||||||
|
snap := webScreenLogSnapshot()
|
||||||
|
if len(snap.Log) == 0 {
|
||||||
|
t.Fatal("no log lines recorded")
|
||||||
|
}
|
||||||
|
if !strings.Contains(snap.Log[0], "Version: 2.2.0 Build: 0201") {
|
||||||
|
t.Errorf("padding should be plain spaces, got %q", snap.Log[0])
|
||||||
|
}
|
||||||
|
for _, line := range snap.Log {
|
||||||
|
if strings.Contains(line, " ") {
|
||||||
|
t.Errorf("log line contains an HTML entity: %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-6
@@ -164,16 +164,18 @@ func Stream(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var bufferMode = bufferModeFor(streamInfo.PlaylistID)
|
||||||
|
|
||||||
// If an UDPxy host is set, and the stream URL is multicast (i.e. starts with 'udp://@'),
|
// If an UDPxy host is set, and the stream URL is multicast (i.e. starts with 'udp://@'),
|
||||||
// then streamInfo.URL needs to be rewritten to point to UDPxy.
|
// then streamInfo.URL needs to be rewritten to point to UDPxy.
|
||||||
if Settings.UDPxy != "" && strings.HasPrefix(streamInfo.URL, "udp://@") {
|
if Settings.UDPxy != "" && strings.HasPrefix(streamInfo.URL, "udp://@") {
|
||||||
streamInfo.URL = fmt.Sprintf("http://%s/udp/%s/", Settings.UDPxy, strings.TrimPrefix(streamInfo.URL, "udp://@"))
|
streamInfo.URL = fmt.Sprintf("http://%s/udp/%s/", Settings.UDPxy, strings.TrimPrefix(streamInfo.URL, "udp://@"))
|
||||||
}
|
}
|
||||||
|
|
||||||
switch Settings.Buffer {
|
switch bufferMode {
|
||||||
|
|
||||||
case "-":
|
case "-":
|
||||||
showInfo(fmt.Sprintf("Buffer:false [%s]", Settings.Buffer))
|
showInfo(fmt.Sprintf("Buffer:false [%s]", bufferMode))
|
||||||
|
|
||||||
case "xteve":
|
case "xteve":
|
||||||
if strings.Contains(streamInfo.URL, "rtsp://") || strings.Contains(streamInfo.URL, "rtp://") {
|
if strings.Contains(streamInfo.URL, "rtsp://") || strings.Contains(streamInfo.URL, "rtp://") {
|
||||||
@@ -187,14 +189,14 @@ func Stream(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showInfo(fmt.Sprintf("Buffer:true [%s]", Settings.Buffer))
|
showInfo(fmt.Sprintf("Buffer:true [%s]", bufferMode))
|
||||||
|
|
||||||
default:
|
default:
|
||||||
showInfo(fmt.Sprintf("Buffer:true [%s]", Settings.Buffer))
|
showInfo(fmt.Sprintf("Buffer:true [%s]", bufferMode))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if Settings.Buffer != "-" {
|
if bufferMode != "-" {
|
||||||
showInfo(fmt.Sprintf("Buffer Size:%d KB", Settings.BufferSize))
|
showInfo(fmt.Sprintf("Buffer Size:%d KB", Settings.BufferSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +204,7 @@ func Stream(w http.ResponseWriter, r *http.Request) {
|
|||||||
showInfo(fmt.Sprintf("Client User-Agent:%s", r.Header.Get("User-Agent")))
|
showInfo(fmt.Sprintf("Client User-Agent:%s", r.Header.Get("User-Agent")))
|
||||||
|
|
||||||
// Prüfen ob der Buffer verwendet werden soll
|
// Prüfen ob der Buffer verwendet werden soll
|
||||||
switch Settings.Buffer {
|
switch bufferMode {
|
||||||
|
|
||||||
case "-":
|
case "-":
|
||||||
showInfo("Streaming URL:" + streamInfo.URL)
|
showInfo("Streaming URL:" + streamInfo.URL)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Date: 2026-09-25
|
Date: 2026-09-25
|
||||||
Scope: whole repository (Go backend, TypeScript UI, Docker, CI, docs)
|
Scope: whole repository (Go backend, TypeScript UI, Docker, CI, docs)
|
||||||
Status: reviewed with maintainer 2026-09-25/26, all open questions resolved, ready to start Phase 0. Checklist lives in `tasks/todo.md`.
|
Status: implemented on branch `improvements` (2026-09-26). Per-item results, deviations and what was left open are in `tasks/todo.md`.
|
||||||
|
|
||||||
## 1. Where the fork stands
|
## 1. Where the fork stands
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -1,7 +1,7 @@
|
|||||||
# xTeVe improvement checklist
|
# xTeVe improvement checklist
|
||||||
|
|
||||||
Detailed rationale, file references and effort estimates: `tasks/improvement-plan.md`.
|
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
|
## Phase 0: Hygiene
|
||||||
- [x] `.gitignore` (`.gocache/`), deleted `.gocache/`, extended `.dockerignore`
|
- [x] `.gitignore` (`.gocache/`), deleted `.gocache/`, extended `.dockerignore`
|
||||||
@@ -47,7 +47,7 @@ Status: Phases 0, 1, 2, 3 done on branch `improvements` (2026-09-26); Phase 5 in
|
|||||||
- [x] i18n dropped: 254 placeholders inlined, `en.json` deleted, only HTML pages templated (`authenticationErr`)
|
- [x] i18n dropped: 254 placeholders inlined, `en.json` deleted, only HTML pages templated (`authenticationErr`)
|
||||||
- [x] `package.json` + `ts/tsconfig.json` (tsc 5.9.3, ES2020, single outFile `html/js/app.js`, committed, CI-verified); 10 dead JS files deleted; 6 tsc errors fixed incl. a real ASI bug in the search shortcut
|
- [x] `package.json` + `ts/tsconfig.json` (tsc 5.9.3, ES2020, single outFile `html/js/app.js`, committed, CI-verified); 10 dead JS files deleted; 6 tsc errors fixed incl. a real ASI bug in the search shortcut
|
||||||
- [x] CI: vet, gofmt check, staticcheck v0.8.1 (config in `staticcheck.conf`), bundle freshness check. `go test -race` still to add once tests exist
|
- [x] CI: vet, gofmt check, staticcheck v0.8.1 (config in `staticcheck.conf`), bundle freshness check. `go test -race` still to add once tests exist
|
||||||
- [ ] Version-tagged images (amd64 only)
|
- [x] Version-tagged images: master publishes `latest`, the commit SHA and the changelog version via a `.tags` file (amd64 only)
|
||||||
- [x] PUID/PGID via su-exec at runtime; static-ffmpeg pinned to 7.1.1; `VOLUME /xteve/config`; `/xteve` removed from `LEGACY_CONFIG_DIRS`; compose files pull from the registry and document SSDP/host networking
|
- [x] PUID/PGID via su-exec at runtime; static-ffmpeg pinned to 7.1.1; `VOLUME /xteve/config`; `/xteve` removed from `LEGACY_CONFIG_DIRS`; compose files pull from the registry and document SSDP/host networking
|
||||||
- [x] `/healthz` endpoint; Dockerfile healthcheck uses it
|
- [x] `/healthz` endpoint; Dockerfile healthcheck uses it
|
||||||
- [x] `README-DEV.md` and fork-specific `README.md` (about, container usage, env vars, security notes)
|
- [x] `README-DEV.md` and fork-specific `README.md` (about, container usage, env vars, security notes)
|
||||||
@@ -59,18 +59,18 @@ Not planned (lineup is ~170 channels, decided 2026-09-25). See plan for the refe
|
|||||||
|
|
||||||
## Phase 5: Frontend architecture
|
## 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`
|
- [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()`
|
- [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
|
- [~] 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)
|
||||||
- [ ] Split `menu_ts.ts`; `addEventListener`; data-driven settings rows
|
- [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
|
||||||
- [ ] Accessibility follow-ups; light theme via `prefers-color-scheme`
|
- [x] Live-region announcements alongside alerts, `lang`, single h1, `<main>` landmark; light theme via `prefers-color-scheme` with `data-theme` override (contrast measured). `<input type=button>` kept because every button style keys on it
|
||||||
- [ ] Consolidate CSS layers
|
- [x] CSS split into tokens / base / components / layout, every colour a token, `screen.css` removed
|
||||||
|
|
||||||
## Phase 6: Optional features
|
## Phase 6: Optional features
|
||||||
- [ ] Regex filters
|
- [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
|
||||||
- [ ] Per-playlist tuner limit and buffer choice
|
- [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
|
||||||
- [ ] Bulk channel edit
|
- [x] Already present in the fork (bulk edit with shift-range selection); nothing to add
|
||||||
- [ ] Versioned backup/restore
|
- [x] Already present: restore checks the backup's version against `System.Compatibility`; writes are now atomic (Phase 2)
|
||||||
- [ ] Dummy EPG durations
|
- [x] Already present: `xTeVe Dummy` offers several durations in the mapping dropdown
|
||||||
|
|
||||||
## Review
|
## Review
|
||||||
|
|
||||||
|
|||||||
@@ -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("<b>x</b>"), "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 <b>x</b>");
|
||||||
|
});
|
||||||
|
|
||||||
|
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"});
|
||||||
|
});
|
||||||
@@ -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, "<b>x</b>"),
|
||||||
|
[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};
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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 = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><title>xTeVe</title></head>
|
||||||
|
<body class="app-shell">
|
||||||
|
<div id="loading" class="none"><div class="loader"></div></div>
|
||||||
|
<div id="popup" class="none"><div id="popup-custom"></div></div>
|
||||||
|
<div id="layout-overlay"></div>
|
||||||
|
<div id="layout">
|
||||||
|
<aside id="menu-wrapper">
|
||||||
|
<nav id="main-menu"></nav>
|
||||||
|
</aside>
|
||||||
|
<main id="shell-main">
|
||||||
|
<header id="shell-header">
|
||||||
|
<button id="menu-toggle" type="button">Menu</button>
|
||||||
|
<p id="connection-indicator" class="status-idle">Connecting...</p>
|
||||||
|
</header>
|
||||||
|
<table id="clientInfo">
|
||||||
|
<tr><td id="version"></td><td id="os"></td><td id="DVR"></td></tr>
|
||||||
|
<tr><td id="uuid"></td><td id="arch"></td><td id="m3u-url"></td></tr>
|
||||||
|
<tr><td id="streams"></td><td id="epgSource"></td><td id="xepg-url"></td></tr>
|
||||||
|
<tr><td id="xepg"></td><td id="errors"></td><td id="warnings"></td></tr>
|
||||||
|
</table>
|
||||||
|
<div id="status-cards"></div>
|
||||||
|
<div id="myStreamsBox" class="notVisible">
|
||||||
|
<table id="activeStreams"></table>
|
||||||
|
<table id="inactiveStreams"></table>
|
||||||
|
</div>
|
||||||
|
<div id="content"></div>
|
||||||
|
<p id="sr-announcer" class="sr-only" aria-live="polite" aria-atomic="true"></p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
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};
|
||||||
@@ -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 id="sr-announcer"[^>]*><\/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");
|
||||||
|
});
|
||||||
|
});
|
||||||
File renamed without changes.
@@ -60,6 +60,35 @@ function showElement(elmID, type) {
|
|||||||
element.className = cssClass;
|
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 = "") {
|
function setConnectionState(state:string, text:string = "") {
|
||||||
|
|
||||||
var label:string = text
|
var label:string = text
|
||||||
@@ -92,9 +121,17 @@ function setConnectionState(state:string, text:string = "") {
|
|||||||
indicator.innerText = label
|
indicator.innerText = label
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeButtonAction(element, buttonID, attribute) {
|
// markChangedOnChange flags a control as edited on "change" so that
|
||||||
var value = element.options[element.selectedIndex].value;
|
// saveSettings / donePopupData pick it up, then runs an optional follow-up.
|
||||||
document.getElementById(buttonID).setAttribute(attribute, value)
|
// 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 {
|
function getLocalData(dataType, id):object {
|
||||||
@@ -501,7 +538,7 @@ function changeChannelNumber(element) {
|
|||||||
var channels = getObjKeys(data)
|
var channels = getObjKeys(data)
|
||||||
|
|
||||||
if (isNaN(newNumber)) {
|
if (isNaN(newNumber)) {
|
||||||
alert("Invalid channel number")
|
alertUser("Invalid channel number")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,7 +621,7 @@ function toggleChannelStatus(id:string) {
|
|||||||
if (channel["x-xmltv-file"] == "-" || channel["x-mapping"] == "-") {
|
if (channel["x-xmltv-file"] == "-" || channel["x-mapping"] == "-") {
|
||||||
|
|
||||||
if (BULK_EDIT == false) {
|
if (BULK_EDIT == false) {
|
||||||
alert(channel["x-name"] + ": Missing XMLTV file / channel")
|
alertUser(channel["x-name"] + ": Missing XMLTV file / channel")
|
||||||
checkbox.checked = false
|
checkbox.checked = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,7 +686,7 @@ function restore() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
alert("File could not be loaded")
|
alertUser("File could not be loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
restore.remove()
|
restore.remove()
|
||||||
@@ -707,7 +744,7 @@ function uploadLogo() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
alert("File could not be loaded")
|
alertUser("File could not be loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
upload.remove()
|
upload.remove()
|
||||||
@@ -144,7 +144,7 @@ function saveWizard() {
|
|||||||
|
|
||||||
if (value.length == 0) {
|
if (value.length == 0) {
|
||||||
var msg = name.toUpperCase() + ": " + "Missing input"
|
var msg = name.toUpperCase() + ": " + "Missing input"
|
||||||
alert(msg)
|
alertUser(msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
+764
@@ -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
|
||||||
|
}
|
||||||
File renamed without changes.
+414
@@ -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
|
||||||
|
}
|
||||||
-2477
File diff suppressed because it is too large.
Load diff
@@ -228,7 +228,7 @@ class Server {
|
|||||||
|
|
||||||
if (response["status"] == false) {
|
if (response["status"] == false) {
|
||||||
setConnectionState("offline")
|
setConnectionState("offline")
|
||||||
alert(response["err"])
|
alertUser(response["err"])
|
||||||
if (response.hasOwnProperty("reload")) {
|
if (response.hasOwnProperty("reload")) {
|
||||||
location.reload()
|
location.reload()
|
||||||
}
|
}
|
||||||
@@ -266,7 +266,7 @@ class Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response.hasOwnProperty("alert")) {
|
if (response.hasOwnProperty("alert")) {
|
||||||
alert(response["alert"])
|
alertUser(response["alert"])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.hasOwnProperty("reload")) {
|
if (response.hasOwnProperty("reload")) {
|
||||||
@@ -295,7 +295,7 @@ class Server {
|
|||||||
setConnectionState(state)
|
setConnectionState(state)
|
||||||
|
|
||||||
if (WS_AVAILABLE == false && !isLogUpdate && requestCmd != "getServerConfig") {
|
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.")
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
+1171
File diff suppressed because it is too large.
Load diff
+342
@@ -0,0 +1,342 @@
|
|||||||
|
// 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.<br>Only change if you know what you are doing.<br>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.<br>Only change if you know what you are doing.<br>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. <br><b>Warning!!!</b> 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.<br>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 <a href='https://github.com/xteve-project/xTeVe-Documentation/blob/master/en/configuration.md#api'>here</a>"},
|
||||||
|
{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.<br>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.<br>Available for: Plex, Emby (HDHR), M3U (with active buffer).<br>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:<br>- Use EPG data from Plex or Emby <br><br>XEPG:<br>- Use of one or more XMLTV files<br>- Channel management<br>- 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.<br>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:<br>- The stream is passed from xTeVe, FFmpeg or VLC to Plex, Emby or M3U Player<br>- Small jerking of the streams can be compensated<br>- HLS / M3U8 support<br>- RTP / RTPS support (only FFmpeg or VLC)<br>- Re-streaming<br>- 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 {
|
||||||
|
var element = document.createElement("H4")
|
||||||
|
element.innerHTML = value
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
createHR():any {
|
||||||
|
var element = document.createElement("HR")
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
// createControl builds the input for one field, filled with the current value.
|
||||||
|
createControl(field:SettingsField, data:any):HTMLElement {
|
||||||
|
var content:PopupContent = new PopupContent()
|
||||||
|
var control:HTMLElement
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
|
||||||
|
case "checkbox":
|
||||||
|
control = content.createCheckbox(field.key);
|
||||||
|
(control as HTMLInputElement).checked = data
|
||||||
|
break
|
||||||
|
|
||||||
|
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", "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")
|
||||||
|
tdLeft.innerHTML = ""
|
||||||
|
|
||||||
|
var tdRight = document.createElement("TD")
|
||||||
|
var pre = document.createElement("PRE")
|
||||||
|
pre.innerHTML = text
|
||||||
|
tdRight.appendChild(pre)
|
||||||
|
|
||||||
|
description.appendChild(tdLeft)
|
||||||
|
description.appendChild(tdRight)
|
||||||
|
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class SettingsCategoryItem extends SettingsCategory {
|
||||||
|
headline:string
|
||||||
|
settingsKeys:string
|
||||||
|
|
||||||
|
constructor(headline:string, settingsKeys:string) {
|
||||||
|
super()
|
||||||
|
this.headline = headline
|
||||||
|
this.settingsKeys = settingsKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
createCategory():void {
|
||||||
|
var headline = this.createCategoryHeadline(this.headline)
|
||||||
|
var settingsKeys = this.settingsKeys
|
||||||
|
|
||||||
|
var doc = document.getElementById(this.DocumentID)
|
||||||
|
doc.appendChild(headline)
|
||||||
|
|
||||||
|
// Tabelle für die Kategorie erstellen
|
||||||
|
|
||||||
|
var table = document.createElement("TABLE")
|
||||||
|
|
||||||
|
var keys = settingsKeys.split(",")
|
||||||
|
|
||||||
|
keys.forEach(settingsKey => {
|
||||||
|
|
||||||
|
switch (settingsKey) {
|
||||||
|
|
||||||
|
case "authentication.pms":
|
||||||
|
case "authentication.m3u":
|
||||||
|
case "authentication.xml":
|
||||||
|
case "authentication.api":
|
||||||
|
if (SERVER["settings"]["authentication.web"] == false) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
var item = this.createSettings(settingsKey)
|
||||||
|
var description = this.createDescription(settingsKey)
|
||||||
|
|
||||||
|
table.appendChild(item)
|
||||||
|
table.appendChild(description)
|
||||||
|
break
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.appendChild(table)
|
||||||
|
doc.appendChild(this.createHR())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSettings() {
|
||||||
|
console.log("SETTINGS");
|
||||||
|
|
||||||
|
for (let i = 0; i < settingsCategory.length; i++) {
|
||||||
|
settingsCategory[i].createCategory()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
console.log("Save Settings");
|
||||||
|
|
||||||
|
var cmd = "saveSettings"
|
||||||
|
var div = document.getElementById("content_settings")
|
||||||
|
var settings = div.getElementsByClassName("changed")
|
||||||
|
|
||||||
|
var newSettings = new Object();
|
||||||
|
|
||||||
|
for (let i = 0; i < settings.length; i++) {
|
||||||
|
|
||||||
|
var name:string
|
||||||
|
var value:any
|
||||||
|
|
||||||
|
switch (settings[i].tagName) {
|
||||||
|
case "INPUT":
|
||||||
|
|
||||||
|
switch ((settings[i] as HTMLInputElement).type) {
|
||||||
|
case "checkbox":
|
||||||
|
name = (settings[i] as HTMLInputElement).name
|
||||||
|
value = (settings[i] as HTMLInputElement).checked
|
||||||
|
newSettings[name] = value
|
||||||
|
break
|
||||||
|
|
||||||
|
case "text":
|
||||||
|
case "password":
|
||||||
|
name = (settings[i] as HTMLInputElement).name
|
||||||
|
value = (settings[i] as HTMLInputElement).value
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case "update":
|
||||||
|
value = value.split(",")
|
||||||
|
value = value.filter(function(e:any) { return e})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "buffer.timeout":
|
||||||
|
value = parseFloat(value)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
newSettings[name] = value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case "SELECT":
|
||||||
|
name = (settings[i] as HTMLSelectElement).name
|
||||||
|
value = (settings[i] as HTMLSelectElement).value
|
||||||
|
|
||||||
|
// Wenn der Wert eine Zahl ist, wird dieser als Zahl gespeichert
|
||||||
|
if(isNaN(value)){
|
||||||
|
newSettings[name] = value
|
||||||
|
} else {
|
||||||
|
newSettings[name] = parseInt(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new Object()
|
||||||
|
data["settings"] = newSettings
|
||||||
|
|
||||||
|
var server:Server = new Server(cmd)
|
||||||
|
server.request(data)
|
||||||
|
}
|
||||||
@@ -1,714 +0,0 @@
|
|||||||
class SettingsCategory {
|
|
||||||
DocumentID:string = "content_settings"
|
|
||||||
createCategoryHeadline(value:string):any {
|
|
||||||
var element = document.createElement("H4")
|
|
||||||
element.innerHTML = value
|
|
||||||
return element
|
|
||||||
}
|
|
||||||
|
|
||||||
createHR():any {
|
|
||||||
var element = document.createElement("HR")
|
|
||||||
return element
|
|
||||||
}
|
|
||||||
|
|
||||||
createSettings(settingsKey:string):any {
|
|
||||||
var setting = document.createElement("TR")
|
|
||||||
var content:PopupContent = 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)
|
|
||||||
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. <br><b>Warning!!!</b> 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:<br>- The stream is passed from xTeVe, FFmpeg or VLC to Plex, Emby or M3U Player<br>- Small jerking of the streams can be compensated<br>- HLS / M3U8 support<br>- RTP / RTPS support (only FFmpeg or VLC)<br>- Re-streaming<br>- Separate tuner limit for each playlist"
|
|
||||||
break
|
|
||||||
|
|
||||||
case "buffer.size.kb":
|
|
||||||
text = "Buffer size in MB.<br>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.<br>Only change if you know what you are doing.<br>Leave blank to set default settings."
|
|
||||||
break
|
|
||||||
|
|
||||||
case "vlc.path":
|
|
||||||
text = "Path to VLC / CVLC binary."
|
|
||||||
break
|
|
||||||
|
|
||||||
case "vlc.options":
|
|
||||||
text = "VLC / CVLC options.<br>Only change if you know what you are doing.<br>Leave blank to set default settings."
|
|
||||||
break
|
|
||||||
|
|
||||||
case "epgSource":
|
|
||||||
text = "PMS:<br>- Use EPG data from Plex or Emby <br><br>XEPG:<br>- Use of one or more XMLTV files<br>- Channel management<br>- M3U / XMLTV export (HTTP link for IPTV apps)"
|
|
||||||
break
|
|
||||||
|
|
||||||
case "tuner":
|
|
||||||
text = "Number of parallel connections that can be established to the provider.<br>Available for: Plex, Emby (HDHR), M3U (with active buffer).<br>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 <a href='https://github.com/xteve-project/xTeVe-Documentation/blob/master/en/configuration.md#api'>here</a>"
|
|
||||||
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.<br>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.<br>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 tdLeft = document.createElement("TD")
|
|
||||||
tdLeft.innerHTML = ""
|
|
||||||
|
|
||||||
var tdRight = document.createElement("TD")
|
|
||||||
var pre = document.createElement("PRE")
|
|
||||||
pre.innerHTML = text
|
|
||||||
tdRight.appendChild(pre)
|
|
||||||
|
|
||||||
description.appendChild(tdLeft)
|
|
||||||
description.appendChild(tdRight)
|
|
||||||
|
|
||||||
return description
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class SettingsCategoryItem extends SettingsCategory {
|
|
||||||
headline:string
|
|
||||||
settingsKeys:string
|
|
||||||
|
|
||||||
constructor(headline:string, settingsKeys:string) {
|
|
||||||
super()
|
|
||||||
this.headline = headline
|
|
||||||
this.settingsKeys = settingsKeys
|
|
||||||
}
|
|
||||||
|
|
||||||
createCategory():void {
|
|
||||||
var headline = this.createCategoryHeadline(this.headline)
|
|
||||||
var settingsKeys = this.settingsKeys
|
|
||||||
|
|
||||||
var doc = document.getElementById(this.DocumentID)
|
|
||||||
doc.appendChild(headline)
|
|
||||||
|
|
||||||
// Tabelle für die Kategorie erstellen
|
|
||||||
|
|
||||||
var table = document.createElement("TABLE")
|
|
||||||
|
|
||||||
var keys = settingsKeys.split(",")
|
|
||||||
|
|
||||||
keys.forEach(settingsKey => {
|
|
||||||
|
|
||||||
switch (settingsKey) {
|
|
||||||
|
|
||||||
case "authentication.pms":
|
|
||||||
case "authentication.m3u":
|
|
||||||
case "authentication.xml":
|
|
||||||
case "authentication.api":
|
|
||||||
if (SERVER["settings"]["authentication.web"] == false) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
var item = this.createSettings(settingsKey)
|
|
||||||
var description = this.createDescription(settingsKey)
|
|
||||||
|
|
||||||
table.appendChild(item)
|
|
||||||
table.appendChild(description)
|
|
||||||
break
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
doc.appendChild(table)
|
|
||||||
doc.appendChild(this.createHR())
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSettings() {
|
|
||||||
console.log("SETTINGS");
|
|
||||||
|
|
||||||
for (let i = 0; i < settingsCategory.length; i++) {
|
|
||||||
settingsCategory[i].createCategory()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSettings() {
|
|
||||||
console.log("Save Settings");
|
|
||||||
|
|
||||||
var cmd = "saveSettings"
|
|
||||||
var div = document.getElementById("content_settings")
|
|
||||||
var settings = div.getElementsByClassName("changed")
|
|
||||||
|
|
||||||
var newSettings = new Object();
|
|
||||||
|
|
||||||
for (let i = 0; i < settings.length; i++) {
|
|
||||||
|
|
||||||
var name:string
|
|
||||||
var value:any
|
|
||||||
|
|
||||||
switch (settings[i].tagName) {
|
|
||||||
case "INPUT":
|
|
||||||
|
|
||||||
switch ((settings[i] as HTMLInputElement).type) {
|
|
||||||
case "checkbox":
|
|
||||||
name = (settings[i] as HTMLInputElement).name
|
|
||||||
value = (settings[i] as HTMLInputElement).checked
|
|
||||||
newSettings[name] = value
|
|
||||||
break
|
|
||||||
|
|
||||||
case "text":
|
|
||||||
name = (settings[i] as HTMLInputElement).name
|
|
||||||
value = (settings[i] as HTMLInputElement).value
|
|
||||||
|
|
||||||
switch (name) {
|
|
||||||
case "update":
|
|
||||||
value = value.split(",")
|
|
||||||
value = value.filter(function(e:any) { return e})
|
|
||||||
break
|
|
||||||
|
|
||||||
case "buffer.timeout":
|
|
||||||
value = parseFloat(value)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
newSettings[name] = value
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
case "SELECT":
|
|
||||||
name = (settings[i] as HTMLSelectElement).name
|
|
||||||
value = (settings[i] as HTMLSelectElement).value
|
|
||||||
|
|
||||||
// Wenn der Wert eine Zahl ist, wird dieser als Zahl gespeichert
|
|
||||||
if(isNaN(value)){
|
|
||||||
newSettings[name] = value
|
|
||||||
} else {
|
|
||||||
newSettings[name] = parseInt(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
var data = new Object()
|
|
||||||
data["settings"] = newSettings
|
|
||||||
|
|
||||||
var server:Server = new Server(cmd)
|
|
||||||
server.request(data)
|
|
||||||
}
|
|
||||||
+10
-7
@@ -11,12 +11,15 @@
|
|||||||
"removeComments": false
|
"removeComments": false
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"network_ts.ts",
|
"network.ts",
|
||||||
"menu_ts.ts",
|
"menu.ts",
|
||||||
"settings_ts.ts",
|
"content.ts",
|
||||||
"logs_ts.ts",
|
"popup.ts",
|
||||||
"base_ts.ts",
|
"xmltv.ts",
|
||||||
"configuration_ts.ts",
|
"settings.ts",
|
||||||
"authentication_ts.ts"
|
"logs.ts",
|
||||||
|
"base.ts",
|
||||||
|
"configuration.ts",
|
||||||
|
"authentication.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+206
@@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ const Name = "xTeVe"
|
|||||||
|
|
||||||
// Version : Version, die Build Nummer wird in der main func geparst.
|
// Version : Version, die Build Nummer wird in der main func geparst.
|
||||||
// Can be overwritten at build time: -ldflags "-X main.Version=..."
|
// Can be overwritten at build time: -ldflags "-X main.Version=..."
|
||||||
var Version = "2.2.0.0201"
|
var Version = "2.3.0.0202"
|
||||||
|
|
||||||
// DBVersion : Datanbank Version
|
// DBVersion : Datanbank Version
|
||||||
const DBVersion = "2.1.0"
|
const DBVersion = "2.1.0"
|
||||||
|
|||||||
Reference in new issue
Block a user