Author SHA1 Message Date
nathan d385e68b31 Release 2.3.0.0202: version-tagged images, changelog
continuous-integration/drone/push Build is passing
- Drone: an image-tags step writes .tags (latest, commit SHA, changelog
  version) for the master publish, so each release can be pulled and
  rolled back by version. The master publish step no longer hardcodes tags.
- Version 2.2.0.0201 -> 2.3.0.0202 in xteve.go and changelog-beta.md, with
  a changelog entry covering the improvements branch.
- README / README-DEV describe the version tag.
2026-10-02 10:54:29 +10:00
nathan e1a6956657 Pin static-ffmpeg to 9.0.2; drop duplicate stream error log
continuous-integration/drone/push Build is passing
- ffmpeg 7.1.1's HLS demuxer rejects segment extensions such as .ec3
  ("not in allowed_extensions"), so streams with E-AC-3 audio died a few
  seconds after starting. The image used to track :latest; pinning to
  7.1.1 regressed it. 9.0.2 includes ec3 in the default allow-list.
- The client loop logged the downloader's error a second time (shown as
  "FFMPEG error () - EC: 0") and bumped the error counter twice.
2026-10-02 10:16:14 +10:00
nathan 6a23996c38 Fix log view showing   entities
continuous-integration/drone/push Build is passing
The server padded log lines by replacing every space with  , which
only worked while the UI inserted log lines as HTML. Since the log is now
rendered as text (inside <pre>, so whitespace is preserved anyway), the
entities showed up literally. Store plain spaces instead.

Test: TestLogLinesContainNoHTMLEntities.
2026-10-02 09:54:34 +10:00
nathan 0b26aa2029 Plan status: implemented on branch improvements
continuous-integration/drone/push Build is passing
2026-09-26 13:39:45 +10:00
nathan 014a3b04d2 Phase 5b and Phase 6: TypeScript restructure, jsdom tests, regex filters and per-playlist buffer in the UI
continuous-integration/drone/push Build is passing
TypeScript:
- menu_ts.ts split into menu.ts, content.ts, popup.ts and xmltv.ts; the
  other files drop their _ts suffix. Bundle order fixed in ts/tsconfig.json.
- All 94 string event handlers (setAttribute("onclick", "javascript: ..."))
  replaced with addEventListener closures. changeButtonAction, which
  rewrote an onclick attribute from select values, is gone.
- Settings rows are generated from one SETTINGS_FIELDS table instead of
  ~20 copy-pasted blocks; rendered HTML is byte-identical to before.
  saveSettings now serialises password inputs, so a changed Plex token is
  actually sent (the server treats the mask as unchanged).
- createLayout rebuilds the menu list only when the set of visible items
  changes, so focus in the menu survives a refresh.
- announce()/alertUser(): alerts are mirrored into an aria-live region.
- Filter popup offers a third type, Regular Expression (regex-filter), and
  the filter table labels it. Playlist popups get a Buffer select
  (default / none / xTeVe / FFmpeg / VLC) saved as the playlist's
  "buffer" parameter; the tuner field stays editable when the
  playlist's own buffer is active.

Tests: tests/ holds 27 jsdom tests (Node's built-in runner, jsdom pinned)
that load the built bundle with a fixture server payload: menu visibility
rules, mapping table renders names as text, settings panel fields, popup
flows, sorting, bulk select, layout refresh keeps nodes, live region,
regex option, buffer select. npm test runs in the Drone webui-check step.
README-DEV documents the layout and the test workflow.
2026-09-26 13:39:35 +10:00
nathan 3532679235 Phase 5c: CSS layers and light theme
continuous-integration/drone/push Build is failing
- html/css split into tokens.css (custom properties), base.css (reset,
  typography, focus, skip link), components.css (buttons, inputs, tables,
  status cards, popups, menu, log lines) and layout.css (shell, sidebar,
  header, breakpoints); screen.css removed. Selectors and declarations
  moved as-is; three pairs of conflicting rules resolved in favour of the
  one that already won the cascade, two empty rules dropped.
- Every literal colour outside tokens.css is now a token (category badge
  borders excepted). color-scheme set so native controls follow.
- Light theme under prefers-color-scheme: light, with explicit
  data-theme="light"/"dark" overrides on :root for a future toggle.
  Body text 13.6:1, muted text 5.4:1, accent 5.1:1 on the light background.
- HTML: lang="en" everywhere, one h1 per page, maintenance page uses
  <main>, an id typo fixed.
2026-09-26 13:34:51 +10:00
nathan 0b7a54d7da Fix Drone YAML, add regex filters and per-playlist buffer mode (backend)
continuous-integration/drone/push Build is passing
- .drone.yml: two command strings contained ": " and were parsed as maps
  by Drone's YAML loader ("cannot unmarshal !!map into string"). Quoted.
- Filters: new type "regex-filter". The rule is a Go regular expression
  tried against the channel name, the group title and the raw attribute
  line; case-insensitive unless the filter is marked case sensitive.
  Patterns are validated when the filter is saved and when rules are
  rebuilt, and compiled once (cached). The two fixed {include}/!{exclude}
  patterns are compiled at package level instead of per stream.
- Per-playlist buffer: a playlist's "buffer" parameter ("-", "xteve",
  "ffmpeg", "vlc") overrides the global setting for that playlist in
  the stream handler, the buffer start, the tuner lookup and the external
  process buffer. Anything else falls back to the global setting.
  (UI controls for both follow with the Phase 5 frontend work.)
- Tests: TestRegexFilter, TestBufferModeFor.
2026-09-26 13:24:11 +10:00
46 changed files with 6963 additions and 5429 deletions

No files matched your search

+19 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+17
View File
@@ -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
+6 -4
View File
@@ -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>
+6 -4
View File
@@ -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
View File
@@ -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;
}
}
+775
View File
@@ -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;
}
+109 -418
View File
@@ -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;
+313
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+6 -4
View File
@@ -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>
+7 -5
View File
@@ -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>
+532
View File
@@ -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
View File
@@ -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
View File
@@ -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
+26
View File
@@ -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
View File
@@ -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)
+72
View File
@@ -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
View File
@@ -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
View File
@@ -109,7 +109,7 @@ func showInfo(str string) {
printLogOnScreen(logMsg, "info") printLogOnScreen(logMsg, "info")
logAppend(strings.Replace(logMsg, " ", "&nbsp;", -1)) logAppend(logMsg)
} }
@@ -137,7 +137,7 @@ func showDebug(str string, level int) {
printLogOnScreen(logMsg, "debug") printLogOnScreen(logMsg, "debug")
logAppend(strings.Replace(logMsg, " ", "&nbsp;", -1)) logAppend(logMsg)
} }
+25
View File
@@ -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, "&nbsp;") {
t.Errorf("log line contains an HTML entity: %q", line)
}
}
}
+8 -6
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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
+218
View File
@@ -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"});
});
+162
View File
@@ -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};
+226
View 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");
});
+127
View File
@@ -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};
+116
View File
@@ -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.
+44 -7
View File
@@ -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
View File
@@ -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
}
View File
File renamed without changes.
+414
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+342
View File
@@ -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)
}
-714
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
}
+1 -1
View File
@@ -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"