prismcast 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/app.js +24 -13
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.d.ts +4 -6
- package/dist/browser/cdp.js +13 -17
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/index.js +3 -3
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/login.js +11 -8
- package/dist/browser/login.js.map +1 -1
- package/dist/browser/precaching.d.ts +1 -1
- package/dist/browser/precaching.js +23 -23
- package/dist/browser/precaching.js.map +1 -1
- package/dist/browser/tuning/directv.js +4 -7
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.js +1 -1
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.js +1 -1
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.js +3 -3
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/sling.js +2 -2
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/channels/index.d.ts +2 -0
- package/dist/channels/index.js +438 -252
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.d.ts +3 -3
- package/dist/config/health.js +13 -18
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +11 -2
- package/dist/config/index.js.map +1 -1
- package/dist/config/persistence.d.ts +59 -0
- package/dist/config/persistence.js +127 -0
- package/dist/config/persistence.js.map +1 -0
- package/dist/config/profiles.js +10 -10
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/servicePacks.d.ts +46 -0
- package/dist/config/{providerPacks.js → servicePacks.js} +28 -31
- package/dist/config/servicePacks.js.map +1 -0
- package/dist/config/services.d.ts +202 -0
- package/dist/config/services.js +758 -0
- package/dist/config/services.js.map +1 -0
- package/dist/config/sites.js +61 -61
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +89 -27
- package/dist/config/userChannels.js +400 -184
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +13 -9
- package/dist/config/userConfig.js +78 -60
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +31 -15
- package/dist/config/userProfiles.js +154 -123
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/channelMap.d.ts +1 -1
- package/dist/hdhr/channelMap.js +4 -7
- package/dist/hdhr/channelMap.js.map +1 -1
- package/dist/hdhr/discover.js +7 -15
- package/dist/hdhr/discover.js.map +1 -1
- package/dist/hdhr/index.js +6 -9
- package/dist/hdhr/index.js.map +1 -1
- package/dist/native/index.d.ts +2 -2
- package/dist/native/index.js +17 -27
- package/dist/native/index.js.map +1 -1
- package/dist/native/intercept.js +86 -89
- package/dist/native/intercept.js.map +1 -1
- package/dist/native/probe.js +2 -2
- package/dist/native/probe.js.map +1 -1
- package/dist/native/proxy.d.ts +2 -2
- package/dist/native/proxy.js +4 -4
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +29 -51
- package/dist/routes/assets.js.map +1 -1
- package/dist/routes/auth.js +3 -3
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/channels.js +5 -11
- package/dist/routes/channels.js.map +1 -1
- package/dist/routes/components.d.ts +0 -8
- package/dist/routes/components.js +0 -19
- package/dist/routes/components.js.map +1 -1
- package/dist/routes/config/channels/index.d.ts +1 -1
- package/dist/routes/config/channels/index.js +1 -1
- package/dist/routes/config/channels/index.js.map +1 -1
- package/dist/routes/config/channels/routes.js +748 -420
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +21 -6
- package/dist/routes/config/channels/table.js +456 -193
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/index.d.ts +2 -2
- package/dist/routes/config/index.js +3 -3
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/{providers.js → services.js} +47 -42
- package/dist/routes/config/services.js.map +1 -0
- package/dist/routes/config/settings.js +88 -49
- package/dist/routes/config/settings.js.map +1 -1
- package/dist/routes/debug.js +5 -7
- package/dist/routes/debug.js.map +1 -1
- package/dist/routes/health.js +1 -1
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/icons.d.ts +16 -0
- package/dist/routes/icons.js +44 -0
- package/dist/routes/icons.js.map +1 -0
- package/dist/routes/index.js +2 -2
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/logs.js +4 -4
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/playlist.d.ts +6 -4
- package/dist/routes/playlist.js +98 -45
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/root/content.js +94 -55
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +620 -478
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +884 -964
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/shared.js +468 -188
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.js +252 -207
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +57 -8
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/routes/services.d.ts +6 -0
- package/dist/routes/{providers.js → services.js} +43 -43
- package/dist/routes/services.js.map +1 -0
- package/dist/routes/ui.d.ts +1 -1
- package/dist/routes/ui.js +3 -1
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +8 -43
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.d.ts +1 -1
- package/dist/service/generators.js +143 -41
- package/dist/service/generators.js.map +1 -1
- package/dist/streaming/codec.d.ts +20 -0
- package/dist/streaming/codec.js +68 -0
- package/dist/streaming/codec.js.map +1 -0
- package/dist/streaming/fmp4Segmenter.d.ts +2 -2
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.d.ts +4 -4
- package/dist/streaming/hls.js +35 -29
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.js +3 -3
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/monitor.d.ts +2 -2
- package/dist/streaming/monitor.js +19 -13
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/preroll.d.ts +15 -14
- package/dist/streaming/preroll.js +32 -12
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/registry.d.ts +2 -2
- package/dist/streaming/registry.js +4 -8
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +4 -4
- package/dist/streaming/setup.js +28 -34
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.js +8 -10
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +4 -3
- package/dist/streaming/statusEmitter.js +3 -3
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +18 -11
- package/dist/types/config.d.ts +3 -2
- package/dist/types/index.d.ts +4 -3
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +9 -9
- package/dist/types/selection.d.ts +1 -1
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +8 -16
- package/dist/types/streaming.js +5 -1
- package/dist/types/streaming.js.map +1 -1
- package/dist/upgrade/commands.js +1 -16
- package/dist/upgrade/commands.js.map +1 -1
- package/dist/utils/chromeFetch.js +1 -1
- package/dist/utils/cliOutput.d.ts +10 -0
- package/dist/utils/cliOutput.js +17 -0
- package/dist/utils/cliOutput.js.map +1 -0
- package/dist/utils/debugFilter.js +3 -3
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/fileLogger.js +4 -3
- package/dist/utils/fileLogger.js.map +1 -1
- package/dist/utils/format.d.ts +21 -4
- package/dist/utils/format.js +45 -7
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logger.js +17 -27
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/morganStream.js +2 -2
- package/dist/utils/morganStream.js.map +1 -1
- package/dist/utils/platform.js +3 -2
- package/dist/utils/platform.js.map +1 -1
- package/dist/utils/retry.js +2 -4
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/streamContext.d.ts +7 -0
- package/dist/utils/streamContext.js +10 -1
- package/dist/utils/streamContext.js.map +1 -1
- package/dist/utils/version.js +4 -4
- package/dist/utils/version.js.map +1 -1
- package/package.json +5 -7
- package/dist/config/providerPacks.d.ts +0 -46
- package/dist/config/providerPacks.js.map +0 -1
- package/dist/config/providers.d.ts +0 -175
- package/dist/config/providers.js +0 -678
- package/dist/config/providers.js.map +0 -1
- package/dist/routes/config/providers.js.map +0 -1
- package/dist/routes/providers.d.ts +0 -6
- package/dist/routes/providers.js.map +0 -1
- /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
|
@@ -2,60 +2,44 @@
|
|
|
2
2
|
*
|
|
3
3
|
* table.ts: Channel table rendering for the PrismCast configuration interface.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { ICON_BOLT, ICON_COPY, ICON_DELETE, ICON_DISABLE, ICON_EDIT, ICON_ENABLE, ICON_FILTER, ICON_HEALTH, ICON_LINK, ICON_LOGIN, ICON_MANAGE, ICON_REVERT, ICON_TRANSFER } from "../../icons.js";
|
|
6
|
+
import { compareChannelSort, getAllServiceTags, getAuthDomainForChannel, getChannelServiceLabel, getChannelServiceTags, getChannelSortKey, getEnabledServices, getPredefinedDomainMap, getServiceGroup, hasMultipleServices, isChannelAvailableByService, isServiceTagEnabled, resolvePredefinedVariant, resolveServiceKey } from "../../../config/services.js";
|
|
6
7
|
import { escapeHtml, formatTimeAgo } from "../../../utils/index.js";
|
|
8
|
+
import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getChannelLogo, getChannelsParseErrorMessage, getPredefinedScopeCounts, getTagRegistry, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel, tagsMatch } from "../../../config/userChannels.js";
|
|
7
9
|
import { getCachedProviderChannels, getProviderDomainMap, getProviderGuideUrls, getProviderModuleInfo } from "../../../browser/channelSelection.js";
|
|
8
10
|
import { getChannelHealth, getDomainAuth } from "../../../config/health.js";
|
|
9
|
-
import { getChannelListing, getChannelLogo, getChannelsParseErrorMessage, getPredefinedScopeCounts, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel } from "../../../config/userChannels.js";
|
|
10
11
|
import { getProfileForChannel, getProfiles } from "../../../config/profiles.js";
|
|
11
12
|
import { CONFIG } from "../../../config/index.js";
|
|
12
13
|
import { PREDEFINED_CHANNELS } from "../../../channels/index.js";
|
|
13
14
|
import { categorizeProfiles } from "../index.js";
|
|
14
15
|
import { generateWizardModal } from "../../components.js";
|
|
15
16
|
/**
|
|
16
|
-
* Generates an annotated
|
|
17
|
-
* icon + text combination. The server just emits the text with data attributes — all icon rendering is client-side through the single
|
|
18
|
-
* @param name - The
|
|
19
|
-
* @param domain - The
|
|
17
|
+
* Generates an annotated service display span. The client-side page-load script processes these elements via serviceIconHtml, rendering the appropriate
|
|
18
|
+
* icon + text combination. The server just emits the text with data attributes — all icon rendering is client-side through the single serviceIconHtml path.
|
|
19
|
+
* @param name - The service display name.
|
|
20
|
+
* @param domain - The service's domain for icon fallback derivation. Undefined for services without a known domain.
|
|
20
21
|
* @param iconUrl - Optional explicit icon URL to try before domain-derived fallbacks.
|
|
21
22
|
* @param small - When true, uses the small icon variant for chips.
|
|
22
23
|
* @returns HTML span string with data attributes for client-side processing.
|
|
23
24
|
*/
|
|
24
|
-
function
|
|
25
|
+
function serviceDisplaySpan(name, domain, iconUrl, small) {
|
|
25
26
|
const domainAttr = domain ? " data-domain=\"" + escapeHtml(domain) + "\"" : "";
|
|
26
27
|
const iconAttr = iconUrl ? " data-icon-url=\"" + escapeHtml(iconUrl) + "\"" : "";
|
|
27
28
|
const sizeAttr = small ? " data-sm" : "";
|
|
28
29
|
return "<span class=\"provider-display\"" + domainAttr + iconAttr + sizeAttr + ">" + escapeHtml(name) + "</span>";
|
|
29
30
|
}
|
|
30
|
-
// SVG icon constants for channel action buttons. Each icon is 14x14px with a 16x16 viewBox, stroke-based with currentColor, and uses round line caps/joins.
|
|
31
|
-
export const ICON_EDIT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
32
|
-
"stroke-linejoin=\"round\"><path d=\"M11.5 1.5l3 3L5 14H2v-3L11.5 1.5z\"/></svg>";
|
|
33
|
-
const ICON_LOGIN = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
34
|
-
"stroke-linejoin=\"round\"><path d=\"M6.5 2H3.5a1 1 0 00-1 1v10a1 1 0 001 1h3\"/><path d=\"M10.5 11l3-3-3-3\"/><path d=\"M13.5 8H6.5\"/></svg>";
|
|
35
|
-
export const ICON_DELETE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
36
|
-
"stroke-linejoin=\"round\"><path d=\"M2 4h12\"/><path d=\"M5 4V2.5a.5.5 0 01.5-.5h5a.5.5 0 01.5.5V4\"/><path d=\"M12.5 4l-.5 9.5a1 1 0 01-1 .5H5a1 1 0 " +
|
|
37
|
-
"01-1-.5L3.5 4\"/></svg>";
|
|
38
|
-
const ICON_ENABLE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
39
|
-
"stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 8l2 2 3.5-4\"/></svg>";
|
|
40
|
-
const ICON_DISABLE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
41
|
-
"stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 5.5l5 5\"/></svg>";
|
|
42
|
-
const ICON_HEALTH = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
|
|
43
|
-
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"1,9 4,9 6,4 8,12 10,7 12,9 15,9\"/></svg>";
|
|
44
|
-
const ICON_REVERT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
45
|
-
"stroke-linejoin=\"round\"><path d=\"M3 8a5 5 0 1 1 1.5 3.5\"/><path d=\"M3 4v4h4\"/></svg>";
|
|
46
|
-
const ICON_COPY = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
47
|
-
"stroke-linejoin=\"round\"><rect x=\"5\" y=\"5\" width=\"9\" height=\"9\" rx=\"1\"/><path d=\"M5 11H3a1 1 0 01-1-1V3a1 1 0 011-1h7a1 1 0 011 1v2\"/></svg>";
|
|
48
31
|
// Optional column definitions for the channels table. These columns can be shown or hidden by the user via the column picker. The order here determines the
|
|
49
|
-
// column order in the table, slotted between
|
|
32
|
+
// column order in the table, slotted between Service and Actions.
|
|
50
33
|
export const OPTIONAL_COLUMNS = [
|
|
51
34
|
{ align: "center", cssClass: "col-chnum", field: "channelNumber", label: "Number", width: "70px" },
|
|
52
35
|
{ align: "center", cssClass: "col-hdhr", field: "hdhrEnabled", label: "HDHR", width: "55px" },
|
|
53
36
|
{ align: "center", cssClass: "col-stationid", field: "stationId", label: "Station ID", width: "100px" },
|
|
54
37
|
{ align: "left", cssClass: "col-profile", field: "profile", label: "Profile", width: "130px" },
|
|
55
|
-
{ align: "left", cssClass: "col-selector", field: "channelSelector", label: "Selector", width: "130px" }
|
|
38
|
+
{ align: "left", cssClass: "col-selector", field: "channelSelector", label: "Selector", width: "130px" },
|
|
39
|
+
{ align: "left", cssClass: "col-tags", field: "tags", label: "Tags", width: "150px" }
|
|
56
40
|
];
|
|
57
|
-
// Total number of columns in the channels table (4 required +
|
|
58
|
-
const TOTAL_COLUMN_COUNT =
|
|
41
|
+
// Total number of columns in the channels table (4 required + 6 optional).
|
|
42
|
+
const TOTAL_COLUMN_COUNT = 10;
|
|
59
43
|
// Valid optional column field names.
|
|
60
44
|
export const VALID_OPTIONAL_COLUMNS = new Set(OPTIONAL_COLUMNS.map((c) => c.field));
|
|
61
45
|
/**
|
|
@@ -74,10 +58,17 @@ function generateTextField(id, name, label, value, options = {}) {
|
|
|
74
58
|
const required = options.required ? " required" : "";
|
|
75
59
|
const pattern = options.pattern ? " pattern=\"" + options.pattern + "\"" : "";
|
|
76
60
|
const placeholder = options.placeholder ? " placeholder=\"" + escapeHtml(options.placeholder) + "\"" : "";
|
|
77
|
-
|
|
78
|
-
|
|
61
|
+
// Modification tracking: compare against the predefined default and render the settings form's "modified" visual treatment when they differ.
|
|
62
|
+
const isModified = (options.defaultValue !== undefined) && (options.defaultValue !== value);
|
|
63
|
+
const modifiedClass = isModified ? " modified" : "";
|
|
64
|
+
const modifiedDot = isModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
|
|
65
|
+
const defaultAttr = (options.defaultValue !== undefined) ? " data-default=\"" + escapeHtml(options.defaultValue) + "\"" : "";
|
|
66
|
+
const resetBtn = isModified ? "<button type=\"button\" class=\"btn-reset\" title=\"Reset to predefined default\" aria-label=\"Reset to predefined default\" " +
|
|
67
|
+
"onclick=\"resetChannelField('" + id + "')\">↻</button>" : "";
|
|
68
|
+
lines.push("<div class=\"form-row" + modifiedClass + "\">");
|
|
69
|
+
lines.push(modifiedDot + "<label for=\"" + id + "\">" + label + "</label>");
|
|
79
70
|
lines.push("<input class=\"form-input\" type=\"" + inputType + "\" id=\"" + id + "\" name=\"" + name + "\"" + required + listAttr + pattern +
|
|
80
|
-
placeholder + " value=\"" + escapeHtml(value) + "\">");
|
|
71
|
+
placeholder + defaultAttr + " value=\"" + escapeHtml(value) + "\">" + resetBtn);
|
|
81
72
|
lines.push("</div>");
|
|
82
73
|
// When a datalist ID is specified, append an empty <datalist> element outside the form-row flex container. The client-side JavaScript populates it dynamically
|
|
83
74
|
// based on the URL field value.
|
|
@@ -97,9 +88,17 @@ function generateTextField(id, name, label, value, options = {}) {
|
|
|
97
88
|
* @param showHint - Whether to show the hint text with profile reference link.
|
|
98
89
|
* @returns Array of HTML strings for the form row.
|
|
99
90
|
*/
|
|
100
|
-
function generateProfileDropdown(id, selectedProfile, profiles, showHint = true) {
|
|
91
|
+
function generateProfileDropdown(id, selectedProfile, profiles, showHint = true, defaultProfile) {
|
|
101
92
|
const lines = [];
|
|
102
93
|
const groups = categorizeProfiles(profiles);
|
|
94
|
+
// Modification tracking for the profile dropdown. Same visual treatment as text fields — border accent, dot, and reset button when the current selection
|
|
95
|
+
// differs from the predefined default.
|
|
96
|
+
const isModified = (defaultProfile !== undefined) && (defaultProfile !== selectedProfile);
|
|
97
|
+
const modifiedClass = isModified ? " modified" : "";
|
|
98
|
+
const modifiedDot = isModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
|
|
99
|
+
const defaultAttr = (defaultProfile !== undefined) ? " data-default=\"" + escapeHtml(defaultProfile) + "\"" : "";
|
|
100
|
+
const resetBtn = isModified ? "<button type=\"button\" class=\"btn-reset\" title=\"Reset to predefined default\" aria-label=\"Reset to predefined default\" " +
|
|
101
|
+
"onclick=\"resetChannelField('" + id + "')\">↻</button>" : "";
|
|
103
102
|
// Helper to generate option elements for a profile.
|
|
104
103
|
const renderOption = (profile) => {
|
|
105
104
|
const selected = (profile.name === selectedProfile) ? " selected" : "";
|
|
@@ -107,9 +106,9 @@ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true)
|
|
|
107
106
|
const displayText = profile.summary ? profile.name + " \u2014 " + profile.summary : profile.name;
|
|
108
107
|
return "<option value=\"" + escapeHtml(profile.name) + "\"" + title + selected + ">" + escapeHtml(displayText) + "</option>";
|
|
109
108
|
};
|
|
110
|
-
lines.push("<div class=\"form-row\">");
|
|
111
|
-
lines.push("<label for=\"" + id + "\">Profile</label>");
|
|
112
|
-
lines.push("<select class=\"form-select field-wide\" id=\"" + id + "\" name=\"profile\">");
|
|
109
|
+
lines.push("<div class=\"form-row" + modifiedClass + "\">");
|
|
110
|
+
lines.push(modifiedDot + "<label for=\"" + id + "\">Profile</label>");
|
|
111
|
+
lines.push("<select class=\"form-select field-wide\" id=\"" + id + "\" name=\"profile\"" + defaultAttr + ">");
|
|
113
112
|
lines.push("<option value=\"\">Autodetect (Recommended)</option>");
|
|
114
113
|
// Fullscreen API profiles (most common).
|
|
115
114
|
if (groups.api.length > 0) {
|
|
@@ -151,7 +150,7 @@ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true)
|
|
|
151
150
|
}
|
|
152
151
|
lines.push("</optgroup>");
|
|
153
152
|
}
|
|
154
|
-
lines.push("</select>");
|
|
153
|
+
lines.push("</select>" + resetBtn);
|
|
155
154
|
lines.push("</div>");
|
|
156
155
|
if (showHint) {
|
|
157
156
|
lines.push("<div class=\"hint\">Autodetect uses predefined profiles for known sites. If video doesn't play or fullscreen fails, " +
|
|
@@ -264,7 +263,7 @@ function generateProfileReference(profiles) {
|
|
|
264
263
|
if (groups.custom.length > 0) {
|
|
265
264
|
lines.push("<div class=\"profile-category\">");
|
|
266
265
|
lines.push("<h4>Custom Profiles</h4>");
|
|
267
|
-
lines.push("<p class=\"category-desc\">User-defined profiles created via the profile builder wizard or imported from
|
|
266
|
+
lines.push("<p class=\"category-desc\">User-defined profiles created via the profile builder wizard or imported from service packs.</p>");
|
|
268
267
|
lines.push("<dl class=\"profile-list\">");
|
|
269
268
|
for (const profile of groups.custom) {
|
|
270
269
|
lines.push("<dt>" + escapeHtml(profile.name) + "</dt>");
|
|
@@ -277,7 +276,7 @@ function generateProfileReference(profiles) {
|
|
|
277
276
|
return lines.join("\n");
|
|
278
277
|
}
|
|
279
278
|
function generateAdvancedFields(idPrefix, options = {}) {
|
|
280
|
-
const { channelNumberValue = "", channelSelectorValue = "", hdhrEnabled = true, showHints = true, stationIdValue = "" } = options;
|
|
279
|
+
const { channelNumberValue = "", channelSelectorValue = "", guideTitleValue = "", hdhrEnabled = true, logoUrlValue = "", showHints = true, stationIdValue = "", tagsValue = "" } = options;
|
|
281
280
|
const lines = [];
|
|
282
281
|
// Advanced fields toggle.
|
|
283
282
|
lines.push("<div class=\"advanced-toggle\" onclick=\"document.getElementById('" + idPrefix +
|
|
@@ -286,8 +285,15 @@ function generateAdvancedFields(idPrefix, options = {}) {
|
|
|
286
285
|
lines.push("<div id=\"" + idPrefix + "-advanced\" class=\"advanced-fields\">");
|
|
287
286
|
// Station ID.
|
|
288
287
|
const stationIdHint = showHints ? "Optional Gracenote station ID for guide data (tvc-guide-stationid)." : undefined;
|
|
289
|
-
|
|
290
|
-
|
|
288
|
+
const defs = options.defaults;
|
|
289
|
+
lines.push(...generateTextField(idPrefix + "-stationId", "stationId", "Station ID", stationIdValue, { defaultValue: defs?.stationId, hint: stationIdHint, placeholder: showHints ? "e.g., 12345" : undefined }));
|
|
290
|
+
// Guide title override. When set, this value replaces the channel name in the M3U playlist's tvg-name attribute and guide placeholder airings.
|
|
291
|
+
const guideTitleHint = showHints ? "Optional title for guide display. When set, overrides the channel name in the M3U playlist (tvg-name)." : undefined;
|
|
292
|
+
lines.push(...generateTextField(idPrefix + "-guideTitle", "guideTitle", "Guide Title", guideTitleValue, { defaultValue: defs?.guideTitle, hint: guideTitleHint, placeholder: showHints ? "e.g., Flighty Airport Delays" : undefined }));
|
|
293
|
+
// Custom logo URL. When set, this value is emitted as the tvg-logo attribute in the M3U playlist.
|
|
294
|
+
const logoUrlHint = showHints ? "Optional logo URL for the channel. Overrides any logo derived from Channels DVR." : undefined;
|
|
295
|
+
lines.push(...generateTextField(idPrefix + "-logoUrl", "logoUrl", "Logo URL", logoUrlValue, { defaultValue: defs?.logoUrl, hint: logoUrlHint, placeholder: showHints ? "https://example.com/logo.png" : undefined, type: "url" }));
|
|
296
|
+
// Channel selector. The guide-based service list is derived from the provider module registry so it stays current as services are added.
|
|
291
297
|
const guideProviderNames = getProviderModuleInfo().map((p) => p.label).sort().join(", ");
|
|
292
298
|
const channelSelectorHint = showHints ?
|
|
293
299
|
"Identifies which channel to select on sites that host multiple live streams. Known values are suggested when the URL matches a supported site. " +
|
|
@@ -295,20 +301,51 @@ function generateAdvancedFields(idPrefix, options = {}) {
|
|
|
295
301
|
"For tile and thumbnail profiles, right-click the channel element \u2192 Inspect \u2192 copy a unique value matching the profile's selector pattern " +
|
|
296
302
|
"(typically a portion of the image src URL)." :
|
|
297
303
|
undefined;
|
|
298
|
-
lines.push(...generateTextField(idPrefix + "-channelSelector", "channelSelector", "Channel Selector", channelSelectorValue, { hint: channelSelectorHint, list: idPrefix + "-selectorList", placeholder: showHints ? "e.g., ESPN" : undefined }));
|
|
304
|
+
lines.push(...generateTextField(idPrefix + "-channelSelector", "channelSelector", "Channel Selector", channelSelectorValue, { defaultValue: defs?.channelSelector, hint: channelSelectorHint, list: idPrefix + "-selectorList", placeholder: showHints ? "e.g., ESPN" : undefined }));
|
|
299
305
|
// Channel number for Channels DVR and Plex integration.
|
|
300
306
|
const channelNumberHint = showHints ?
|
|
301
307
|
"Optional numeric channel number for guide matching in Channels DVR and Plex." :
|
|
302
308
|
undefined;
|
|
303
|
-
lines.push(...generateTextField(idPrefix + "-channelNumber", "channelNumber", "Channel Number", channelNumberValue, { hint: channelNumberHint, placeholder: showHints ? "e.g., 501" : undefined }));
|
|
309
|
+
lines.push(...generateTextField(idPrefix + "-channelNumber", "channelNumber", "Channel Number", channelNumberValue, { defaultValue: defs?.channelNumber, hint: channelNumberHint, placeholder: showHints ? "e.g., 501" : undefined }));
|
|
310
|
+
// Tags field: checkbox grid of the active tag vocabulary. Checked tags are collected into a hidden input as a comma-separated value on form submission.
|
|
311
|
+
// This prevents users from entering tag names that don't exist in the managed vocabulary.
|
|
312
|
+
const vocabulary = getActiveTagVocabulary();
|
|
313
|
+
const currentTags = tagsValue ? tagsValue.split(",").map((t) => t.trim()).filter((t) => t.length > 0) : [];
|
|
314
|
+
if (vocabulary.length > 0) {
|
|
315
|
+
const tagsModified = (defs?.tags !== undefined) && (defs.tags !== tagsValue);
|
|
316
|
+
const tagsModifiedClass = tagsModified ? " modified" : "";
|
|
317
|
+
const tagsModifiedDot = tagsModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
|
|
318
|
+
const tagsDefaultAttr = (defs?.tags !== undefined) ? " data-default=\"" + escapeHtml(defs.tags) + "\"" : "";
|
|
319
|
+
lines.push("<div class=\"form-row" + tagsModifiedClass + "\">");
|
|
320
|
+
lines.push(tagsModifiedDot + "<label>Tags</label>");
|
|
321
|
+
lines.push("<input type=\"hidden\" name=\"tags\" id=\"" + idPrefix + "-tags-hidden\" value=\"" + escapeHtml(tagsValue) + "\"" + tagsDefaultAttr + ">");
|
|
322
|
+
lines.push("<div class=\"tag-checkbox-grid\">");
|
|
323
|
+
for (const tag of vocabulary) {
|
|
324
|
+
const checked = currentTags.some((t) => tagsMatch(t, tag)) ? " checked" : "";
|
|
325
|
+
lines.push("<label class=\"tag-checkbox-label\">" +
|
|
326
|
+
"<input type=\"checkbox\" class=\"tag-checkbox\" data-tag=\"" + escapeHtml(tag) + "\"" + checked +
|
|
327
|
+
" onchange=\"updateTagsHidden(this.closest('.form-row'))\">" +
|
|
328
|
+
"<span class=\"tag-badge\">" + escapeHtml(tag) + "</span></label>");
|
|
329
|
+
}
|
|
330
|
+
lines.push("</div>");
|
|
331
|
+
if (showHints) {
|
|
332
|
+
lines.push("<div class=\"hint\">Select tags for playlist filtering. Manage available tags via Manage Channels > Manage Tags.</div>");
|
|
333
|
+
}
|
|
334
|
+
lines.push("</div>");
|
|
335
|
+
}
|
|
304
336
|
// HDHomeRun lineup inclusion. A hidden input provides the "false" value when the checkbox is unchecked (unchecked checkboxes are not submitted in FormData).
|
|
305
337
|
// When checked, the checkbox value "true" overwrites the hidden input's "false" since it appears later in DOM order.
|
|
306
338
|
const hdhrChecked = hdhrEnabled ? " checked" : "";
|
|
339
|
+
const hdhrDefaultEnabled = defs?.hdhrEnabled ?? true;
|
|
340
|
+
const hdhrModified = (defs?.hdhrEnabled !== undefined) && (hdhrEnabled !== hdhrDefaultEnabled);
|
|
341
|
+
const hdhrModifiedClass = hdhrModified ? " modified" : "";
|
|
342
|
+
const hdhrModifiedDot = hdhrModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
|
|
343
|
+
const hdhrDefaultAttr = (defs?.hdhrEnabled !== undefined) ? " data-default=\"" + String(hdhrDefaultEnabled) + "\"" : "";
|
|
307
344
|
const hdhrHint = showHints ? "When unchecked, this channel is hidden from the HDHomeRun lineup and not available in Plex." : undefined;
|
|
308
|
-
lines.push("<div class=\"form-row form-row-checkbox\">");
|
|
309
|
-
lines.push("<label for=\"" + idPrefix + "-hdhrEnabled\">Include in HDHomeRun/Plex Lineup</label>");
|
|
345
|
+
lines.push("<div class=\"form-row form-row-checkbox" + hdhrModifiedClass + "\">");
|
|
346
|
+
lines.push(hdhrModifiedDot + "<label for=\"" + idPrefix + "-hdhrEnabled\">Include in HDHomeRun/Plex Lineup</label>");
|
|
310
347
|
lines.push("<input type=\"hidden\" name=\"hdhrEnabled\" value=\"false\">");
|
|
311
|
-
lines.push("<input type=\"checkbox\" id=\"" + idPrefix + "-hdhrEnabled\" name=\"hdhrEnabled\" value=\"true\"" + hdhrChecked + ">");
|
|
348
|
+
lines.push("<input type=\"checkbox\" id=\"" + idPrefix + "-hdhrEnabled\" name=\"hdhrEnabled\" value=\"true\"" + hdhrChecked + hdhrDefaultAttr + ">");
|
|
312
349
|
lines.push("</div>");
|
|
313
350
|
if (hdhrHint) {
|
|
314
351
|
lines.push("<div class=\"hint\">" + hdhrHint + "</div>");
|
|
@@ -318,8 +355,8 @@ function generateAdvancedFields(idPrefix, options = {}) {
|
|
|
318
355
|
}
|
|
319
356
|
/**
|
|
320
357
|
* Generates JavaScript variables for channel selector datalist population. Produces three variables: `channelSelectorsByDomain` maps URL hostnames to known
|
|
321
|
-
* channel selector values (from predefined channels and cached
|
|
322
|
-
* client-side async discovery, and `
|
|
358
|
+
* channel selector values (from predefined channels and cached service discovery), `serviceByDomain` maps service guide URL hostnames to service slugs for
|
|
359
|
+
* client-side async discovery, and `serviceGuideUrl` maps service slugs to their guide URLs for URL correction hints. Embedded as a `<script>` block in the
|
|
323
360
|
* channels panel.
|
|
324
361
|
*
|
|
325
362
|
* @returns JavaScript variable declarations ready to embed in a `<script>` tag.
|
|
@@ -344,8 +381,8 @@ function generateChannelSelectorData() {
|
|
|
344
381
|
byDomain[hostname] ??= [];
|
|
345
382
|
byDomain[hostname].push({ label: channel.name ?? selector, stationId: channel.stationId, value: selector });
|
|
346
383
|
}
|
|
347
|
-
// Merge cached
|
|
348
|
-
// value is not already present for that domain. This enriches the datalist with the full
|
|
384
|
+
// Merge cached service-discovered channels into the domain map. Predefined entries take precedence — we only add discovered channels whose channelSelector
|
|
385
|
+
// value is not already present for that domain. This enriches the datalist with the full service lineup when precaching or prior discovery has run.
|
|
349
386
|
for (const provider of getCachedProviderChannels()) {
|
|
350
387
|
seen[provider.hostname] ??= new Set();
|
|
351
388
|
byDomain[provider.hostname] ??= [];
|
|
@@ -356,26 +393,104 @@ function generateChannelSelectorData() {
|
|
|
356
393
|
}
|
|
357
394
|
}
|
|
358
395
|
}
|
|
359
|
-
// Build the hostname→slug map and slug→guideUrl map for all
|
|
360
|
-
//
|
|
361
|
-
const
|
|
362
|
-
const
|
|
396
|
+
// Build the hostname→slug map and slug→guideUrl map for all services (including those with cold caches) so the client-side fetch can trigger discovery for any
|
|
397
|
+
// service domain and the URL hint can suggest the correct guide URL.
|
|
398
|
+
const serviceByDomain = getProviderDomainMap();
|
|
399
|
+
const serviceGuideUrl = getProviderGuideUrls();
|
|
363
400
|
// Sort entries within each domain alphabetically by label for consistent ordering in the datalist dropdown.
|
|
364
401
|
for (const entries of Object.values(byDomain)) {
|
|
365
402
|
entries.sort((a, b) => a.label.localeCompare(b.label));
|
|
366
403
|
}
|
|
367
404
|
return "var channelSelectorsByDomain = " + JSON.stringify(byDomain) + ";\n" +
|
|
368
|
-
"var
|
|
369
|
-
"var
|
|
405
|
+
"var serviceByDomain = " + JSON.stringify(serviceByDomain) + ";\n" +
|
|
406
|
+
"var serviceGuideUrl = " + JSON.stringify(serviceGuideUrl) + ";\n" +
|
|
407
|
+
"var predefinedByDomain = " + JSON.stringify(getPredefinedDomainMap()) + ";";
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Generates the tag column filter dropdown content. This is the single source of truth for the filter checkbox list — used for both the initial header render
|
|
411
|
+
* and for incremental updates after tag CRUD operations. The dropdown shell (button, dropdown-menu wrapper) is rendered by the header generator; this function
|
|
412
|
+
* provides only the inner content (checkbox labels + "Show All" item).
|
|
413
|
+
* @returns HTML string for the filter dropdown content.
|
|
414
|
+
*/
|
|
415
|
+
export function generateTagFilterContent() {
|
|
416
|
+
const vocabulary = getActiveTagVocabulary();
|
|
417
|
+
const lines = [];
|
|
418
|
+
for (const tag of vocabulary) {
|
|
419
|
+
lines.push("<label class=\"provider-option\" onclick=\"event.stopPropagation()\">" +
|
|
420
|
+
"<input type=\"checkbox\" class=\"tag-filter-checkbox\" data-tag=\"" + escapeHtml(tag) + "\" checked onchange=\"applyTagColumnFilter()\"> " +
|
|
421
|
+
escapeHtml(tag) + "</label>");
|
|
422
|
+
}
|
|
423
|
+
if (vocabulary.length > 0) {
|
|
424
|
+
lines.push("<div class=\"dropdown-divider\"></div>");
|
|
425
|
+
lines.push("<div class=\"dropdown-item\" id=\"tag-filter-toggle\" onclick=\"event.stopPropagation(); toggleTagColumnFilter()\">Show None</div>");
|
|
426
|
+
}
|
|
427
|
+
return lines.join("");
|
|
370
428
|
}
|
|
371
429
|
/**
|
|
372
|
-
* Generates the
|
|
373
|
-
*
|
|
430
|
+
* Generates the Tag Management modal body HTML. This is the single source of truth for tag manager content — used for both the initial server render and for
|
|
431
|
+
* incremental updates after tag CRUD operations. The endpoints return this HTML in the response so the client can replace the modal content without a page reload.
|
|
432
|
+
* @returns HTML string for the tag manager body (tag list, input field, deleted tags section).
|
|
433
|
+
*/
|
|
434
|
+
export function generateTagManagerBody() {
|
|
435
|
+
const vocabulary = getActiveTagVocabulary();
|
|
436
|
+
const registry = getTagRegistry();
|
|
437
|
+
// Build the tag list HTML. Each tag is a badge with a delete button and inline rename on click.
|
|
438
|
+
const tagListItems = [];
|
|
439
|
+
for (const tag of vocabulary) {
|
|
440
|
+
tagListItems.push("<div class=\"tag-manager-item\" data-tag=\"" + escapeHtml(tag) + "\">" +
|
|
441
|
+
"<span class=\"tag-badge tag-editable\" title=\"Click to rename\" onclick=\"startTagRename(this, '" + escapeHtml(tag) +
|
|
442
|
+
"')\">" + escapeHtml(tag) + "</span>" +
|
|
443
|
+
"<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete tag\" onclick=\"deleteTag('" + escapeHtml(tag) +
|
|
444
|
+
"')\">" + ICON_DELETE + "</button></div>");
|
|
445
|
+
}
|
|
446
|
+
// Deleted predefined tags section — show restore option for tags the user has deleted.
|
|
447
|
+
const deletedItems = [];
|
|
448
|
+
for (const tag of registry.deletedTags) {
|
|
449
|
+
deletedItems.push("<div class=\"tag-manager-item tag-deleted\" data-tag=\"" + escapeHtml(tag) + "\">" +
|
|
450
|
+
"<span class=\"tag-badge tag-badge-deleted\">" + escapeHtml(tag) + "</span> <span class=\"tag-annotation\">(deleted)</span>" +
|
|
451
|
+
"<button type=\"button\" class=\"btn-icon\" title=\"Restore tag\" onclick=\"restoreTag('" + escapeHtml(tag) +
|
|
452
|
+
"')\">" + ICON_REVERT + "</button></div>");
|
|
453
|
+
}
|
|
454
|
+
return "<div class=\"tag-manager\">" +
|
|
455
|
+
"<p class=\"wizard-hint\">Create and manage the tag vocabulary. Tags can be assigned to channels via the channel table " +
|
|
456
|
+
"for playlist filtering and organization.</p>" +
|
|
457
|
+
"<div class=\"tag-manager-add\">" +
|
|
458
|
+
"<input type=\"text\" id=\"tag-manager-input\" placeholder=\"New tag name\" maxlength=\"30\" " +
|
|
459
|
+
"pattern=\"[a-zA-Z0-9]([a-zA-Z0-9 -]*[a-zA-Z0-9])?\" onkeydown=\"if(event.key==='Enter'){event.preventDefault();createTag();}\">" +
|
|
460
|
+
"<button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createTag()\">Add</button>" +
|
|
461
|
+
"</div>" +
|
|
462
|
+
"<div id=\"tag-manager-error\" class=\"wizard-error\" style=\"display: none;\"></div>" +
|
|
463
|
+
"<div id=\"tag-manager-list\" class=\"tag-manager-list\">" +
|
|
464
|
+
(tagListItems.length > 0 ? tagListItems.join("") : "<div class=\"empty-state-text\">No tags defined.</div>") +
|
|
465
|
+
"</div>" +
|
|
466
|
+
(deletedItems.length > 0 ? "<div class=\"tag-manager-section-label\">Deleted Predefined Tags</div>" +
|
|
467
|
+
"<div id=\"tag-manager-deleted\" class=\"tag-manager-list\">" + deletedItems.join("") + "</div>" : "") +
|
|
468
|
+
"</div>";
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Generates the Tag Management modal shell using the wizard modal infrastructure for consistent styling.
|
|
472
|
+
* @returns HTML string for the complete tag management modal.
|
|
473
|
+
*/
|
|
474
|
+
function generateTagManagementModal() {
|
|
475
|
+
return generateWizardModal({
|
|
476
|
+
body: generateTagManagerBody(),
|
|
477
|
+
buttons: [
|
|
478
|
+
{ label: "Done", onclick: "closeTagManager()", position: "right", variant: "primary" }
|
|
479
|
+
],
|
|
480
|
+
id: "tag-manager-modal",
|
|
481
|
+
maxWidth: "420px",
|
|
482
|
+
onClose: "closeTagManager()",
|
|
483
|
+
title: "Manage Tags"
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Generates the Browse Channels 2-step wizard modal. Step 1 is the service picker grid, step 2 is channel discovery and management. Channel state detection
|
|
488
|
+
* (new, switch, current) is handled server-side by the annotated discovery response - no client-side matching logic is needed. Service and guide URL data are
|
|
374
489
|
* embedded as JSON data blocks for the client-side wizard controller.
|
|
375
490
|
* @returns HTML string for the browse modal.
|
|
376
491
|
*/
|
|
377
492
|
function generateBrowseModal() {
|
|
378
|
-
// Build the
|
|
493
|
+
// Build the service info array from the provider module registry. The client uses this to render the service picker.
|
|
379
494
|
const providers = getProviderModuleInfo();
|
|
380
495
|
const guideUrls = getProviderGuideUrls();
|
|
381
496
|
return generateWizardModal({
|
|
@@ -386,26 +501,26 @@ function generateBrowseModal() {
|
|
|
386
501
|
],
|
|
387
502
|
contentId: "browse-content",
|
|
388
503
|
dataBlocks: [
|
|
389
|
-
"<script type=\"application/json\" id=\"browse-
|
|
504
|
+
"<script type=\"application/json\" id=\"browse-services-data\">" + JSON.stringify(providers) + "</script>",
|
|
390
505
|
"<script type=\"application/json\" id=\"browse-guide-urls-data\">" + JSON.stringify(guideUrls) + "</script>"
|
|
391
506
|
],
|
|
392
|
-
description: "Manage your channel lineup by
|
|
507
|
+
description: "Manage your channel lineup by service. Add new channels, switch services for existing channels, or remove channels you no longer need.",
|
|
393
508
|
id: "browse-modal",
|
|
394
|
-
steps: ["
|
|
509
|
+
steps: ["Service", "Channels"],
|
|
395
510
|
title: "Browse Channels",
|
|
396
511
|
titleId: "browse-title"
|
|
397
512
|
});
|
|
398
513
|
}
|
|
399
514
|
/**
|
|
400
|
-
* Generates the
|
|
515
|
+
* Generates the Service Setup 3-step wizard modal. Steps: Services (multi-select), Sign In (sequential auth), Channels (summary + finish). The
|
|
401
516
|
* setupCompleted flag is embedded as a data attribute so the client can auto-show the wizard on first visit.
|
|
402
517
|
* @returns HTML string for the setup wizard modal.
|
|
403
518
|
*/
|
|
404
519
|
function generateSetupWizardModal() {
|
|
405
|
-
const enabled =
|
|
406
|
-
// Build
|
|
407
|
-
// (domain, iconUrl, displayName). The enabled field lets the client pre-check
|
|
408
|
-
const tags =
|
|
520
|
+
const enabled = getEnabledServices();
|
|
521
|
+
// Build service data with enabled state from the current service filter. getAllServiceTags is the single source of truth for service metadata
|
|
522
|
+
// (domain, iconUrl, displayName). The enabled field lets the client pre-check services on re-run without reading DOM state.
|
|
523
|
+
const tags = getAllServiceTags()
|
|
409
524
|
.filter((t) => t.tag !== "direct")
|
|
410
525
|
.map((t) => ({ ...t, enabled: enabled.includes(t.tag) }));
|
|
411
526
|
return generateWizardModal({
|
|
@@ -418,14 +533,14 @@ function generateSetupWizardModal() {
|
|
|
418
533
|
contentId: "setup-content",
|
|
419
534
|
dataAttributes: { "setup-completed": CONFIG.channels.setupCompleted ? "true" : "false" },
|
|
420
535
|
dataBlocks: [
|
|
421
|
-
"<script type=\"application/json\" id=\"setup-
|
|
536
|
+
"<script type=\"application/json\" id=\"setup-services-data\">" + JSON.stringify(tags) + "</script>"
|
|
422
537
|
],
|
|
423
|
-
description: "Set up your streaming
|
|
538
|
+
description: "Set up your streaming services in three steps: select your services, sign in, and choose your channels.",
|
|
424
539
|
errorId: "setup-error",
|
|
425
540
|
id: "setup-modal",
|
|
426
|
-
steps: ["
|
|
541
|
+
steps: ["Services", "Sign In", "Channels"],
|
|
427
542
|
stepsId: "setup-steps",
|
|
428
|
-
title: "
|
|
543
|
+
title: "Service Setup",
|
|
429
544
|
titleId: "setup-title"
|
|
430
545
|
});
|
|
431
546
|
}
|
|
@@ -451,56 +566,66 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
451
566
|
const isPredefined = isPredefinedChannel(key);
|
|
452
567
|
const isOverride = isPredefined && isUser;
|
|
453
568
|
const isDisabled = isPredefinedChannelDisabled(key);
|
|
454
|
-
const
|
|
455
|
-
// Check if this channel has multiple
|
|
456
|
-
const
|
|
457
|
-
// Build the
|
|
458
|
-
const
|
|
459
|
-
// Generate display row. User channels
|
|
569
|
+
const isAvailableByService = isChannelAvailableByService(key);
|
|
570
|
+
// Check if this channel has multiple services.
|
|
571
|
+
const serviceGroup = getServiceGroup(key);
|
|
572
|
+
// Build the service tags data attribute for client-side filtering.
|
|
573
|
+
const serviceTags = getChannelServiceTags(key).join(",");
|
|
574
|
+
// Generate display row. User-created channels and predefined overrides get distinct CSS classes — user-created channels have a subtle background tint
|
|
575
|
+
// ("user-channel"), while predefined overrides get a left-border accent and blue dot indicator via CSS ::before ("channel-override") matching the settings
|
|
576
|
+
// form's visual language for "modified from defaults." Disabled and service-filtered rows get additional classes for opacity and visibility control.
|
|
460
577
|
const displayLines = [];
|
|
461
578
|
const rowClasses = [];
|
|
462
|
-
if (
|
|
579
|
+
if (isOverride) {
|
|
580
|
+
rowClasses.push("channel-override");
|
|
581
|
+
}
|
|
582
|
+
else if (isUser) {
|
|
463
583
|
rowClasses.push("user-channel");
|
|
464
584
|
}
|
|
465
585
|
if (isDisabled) {
|
|
466
586
|
rowClasses.push("channel-disabled");
|
|
467
587
|
}
|
|
468
|
-
if (!
|
|
588
|
+
if (!isAvailableByService) {
|
|
469
589
|
rowClasses.push("channel-unavailable");
|
|
470
590
|
}
|
|
471
591
|
const rowClassAttr = (rowClasses.length > 0) ? " class=\"" + rowClasses.join(" ") + "\"" : "";
|
|
472
|
-
|
|
592
|
+
const rowTitleAttr = isOverride ? " title=\"Customized from predefined defaults\"" : "";
|
|
593
|
+
// Compute effective tags early — used for both the row data attribute (tag column filter) and the Tags column cell rendering below.
|
|
594
|
+
const effectiveTags = getChannelEffectiveTags(channel);
|
|
595
|
+
const channelTagsAttr = (effectiveTags.length > 0) ? " data-channel-tags=\"" + escapeHtml(effectiveTags.join(",")) + "\"" : "";
|
|
596
|
+
displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + rowTitleAttr + " data-provider-tags=\"" + escapeHtml(serviceTags) +
|
|
597
|
+
"\"" + channelTagsAttr + ">");
|
|
473
598
|
displayLines.push("<td class=\"ch-key\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "key")) + "\">" + escapeHtml(key) + "</td>");
|
|
474
599
|
const channelLogoUrl = getChannelLogo(key) ?? "";
|
|
475
600
|
const logoAttr = channelLogoUrl ? " data-logo=\"" + escapeHtml(channelLogoUrl) + "\"" : "";
|
|
476
601
|
displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "name")) + "\"" + logoAttr +
|
|
477
602
|
"><span class=\"channel-name-cell\">" + escapeHtml(channel.name ?? key) + "</span></td>");
|
|
478
|
-
//
|
|
479
|
-
// label alongside the
|
|
480
|
-
displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "
|
|
481
|
-
const labelHidden =
|
|
482
|
-
const contentHidden =
|
|
483
|
-
displayLines.push("<em class=\"no-provider-label\"" + labelHidden + ">No available
|
|
484
|
-
if (
|
|
485
|
-
// Multi-
|
|
486
|
-
// options get the hidden attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls
|
|
603
|
+
// Service column: dropdown for multi-service channels, static service name for single-service. Both states always render a hidden "No available services"
|
|
604
|
+
// label alongside the service content so that client-side channelTable.filter() can toggle between them without a page reload.
|
|
605
|
+
displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "service")) + "\">");
|
|
606
|
+
const labelHidden = isAvailableByService ? " style=\"display:none\"" : "";
|
|
607
|
+
const contentHidden = isAvailableByService ? "" : " style=\"display:none\"";
|
|
608
|
+
displayLines.push("<em class=\"no-provider-label\"" + labelHidden + ">No available services</em>");
|
|
609
|
+
if (hasMultipleServices(key) && serviceGroup) {
|
|
610
|
+
// Multi-service: render ALL variants with data-provider-tag attributes so client-side JS can filter options when the service selection changes. Filtered-out
|
|
611
|
+
// options get the hidden attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls channelTable.filter()
|
|
487
612
|
// to remove them from the DOM.
|
|
488
|
-
const currentSelection =
|
|
613
|
+
const currentSelection = resolveServiceKey(key);
|
|
489
614
|
displayLines.push("<select class=\"provider-select\" data-channel=\"" + escapeHtml(key) +
|
|
490
|
-
"\" title=\"Choose which streaming
|
|
491
|
-
for (const variant of
|
|
615
|
+
"\" title=\"Choose which streaming service delivers this channel\" onchange=\"updateServiceSelection(this)\"" + contentHidden + ">");
|
|
616
|
+
for (const variant of serviceGroup.variants) {
|
|
492
617
|
const selected = (variant.key === currentSelection) ? " selected" : "";
|
|
493
|
-
const optionHidden = !
|
|
618
|
+
const optionHidden = !isServiceTagEnabled(variant.tag) ? " hidden" : "";
|
|
494
619
|
displayLines.push("<option value=\"" + escapeHtml(variant.key) + "\" data-provider-tag=\"" + escapeHtml(variant.tag) + "\"" + selected + optionHidden + ">" +
|
|
495
620
|
escapeHtml(variant.label) + "</option>");
|
|
496
621
|
}
|
|
497
622
|
displayLines.push("</select>");
|
|
498
623
|
}
|
|
499
624
|
else {
|
|
500
|
-
// Single-
|
|
501
|
-
// channels with explicit profile assignments show the profile's
|
|
625
|
+
// Single-service: wrap the service name in a span so client-side JS can toggle it with the no-service label. Uses profile-aware label resolution so
|
|
626
|
+
// channels with explicit profile assignments show the profile's service name rather than the built-in name for the URL domain.
|
|
502
627
|
displayLines.push("<span class=\"provider-name\"" + contentHidden + ">" +
|
|
503
|
-
escapeHtml(
|
|
628
|
+
escapeHtml(getChannelServiceLabel(channel)) + "</span>");
|
|
504
629
|
}
|
|
505
630
|
displayLines.push("</td>");
|
|
506
631
|
// Optional columns: Number, HDHR, Station ID, Profile, Selector. All five are always rendered; visibility is controlled by CSS classes on the table element.
|
|
@@ -525,7 +650,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
525
650
|
else {
|
|
526
651
|
const resolved = getProfileForChannel(channel);
|
|
527
652
|
if (resolved.profileName !== "default") {
|
|
528
|
-
const label =
|
|
653
|
+
const label = getChannelServiceLabel(channel);
|
|
529
654
|
displayLines.push("<td class=\"col-profile\" data-sort-value=\"" + profileSortKey +
|
|
530
655
|
"\"><span class=\"text-muted\">" + escapeHtml(label + " (auto)") + "</span></td>");
|
|
531
656
|
}
|
|
@@ -535,18 +660,26 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
535
660
|
}
|
|
536
661
|
displayLines.push("<td class=\"col-selector\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "channelSelector")) + "\">" +
|
|
537
662
|
(channel.channelSelector ? escapeHtml(channel.channelSelector) : "<span class=\"text-muted\">–</span>") + "</td>");
|
|
663
|
+
// Tags column: render effective tags as pills. Clicking the cell opens a shared portal dropdown (rendered in <body>, positioned via getBoundingClientRect)
|
|
664
|
+
// for inline tag editing. The cell carries data-key and data-tags so the dropdown can populate the correct checked state.
|
|
665
|
+
const tagsHtml = (effectiveTags.length > 0) ?
|
|
666
|
+
effectiveTags.map((tag) => "<span class=\"tag-badge\">" + escapeHtml(tag) + "</span>").join(" ") :
|
|
667
|
+
"<span class=\"text-muted\">–</span>";
|
|
668
|
+
displayLines.push("<td class=\"col-tags editable-cell dropdown\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "tags")) +
|
|
669
|
+
"\" data-key=\"" + cellKey + "\" data-tags=\"" + escapeHtml(effectiveTags.join(",")) +
|
|
670
|
+
"\" onclick=\"toggleInlineTagDropdown(this)\">" + tagsHtml + "</td>");
|
|
538
671
|
// Actions column with icon buttons. Five positions per row: Edit (always), Login/placeholder, Health/placeholder, context-sensitive, and Copy URL.
|
|
539
672
|
displayLines.push("<td>");
|
|
540
673
|
displayLines.push("<div class=\"btn-group\">");
|
|
541
674
|
const escapedKey = escapeHtml(key);
|
|
542
|
-
// Resolve the auth domain for the currently selected
|
|
543
|
-
const variantKey =
|
|
675
|
+
// Resolve the auth domain for the currently selected service variant. Used for both login icon color and health icon lookups.
|
|
676
|
+
const variantKey = resolveServiceKey(key);
|
|
544
677
|
const authDomain = getAuthDomainForChannel(variantKey);
|
|
545
678
|
// Position 1: Edit (all channels).
|
|
546
679
|
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-edit\" title=\"Edit\" aria-label=\"Edit\" onclick=\"showEditForm('" + escapedKey +
|
|
547
680
|
"')\">" + ICON_EDIT + "</button>");
|
|
548
681
|
// Position 2: Login for enabled channels (with domain auth color), placeholder for disabled predefined. Custom channels (user-defined, not predefined) skip
|
|
549
|
-
// login coloring because they have no
|
|
682
|
+
// login coloring because they have no service concept.
|
|
550
683
|
if (!isDisabled) {
|
|
551
684
|
const authTimestamp = (isPredefined || isOverride) ? getDomainAuth(authDomain) : null;
|
|
552
685
|
const loginColorClass = authTimestamp ? " health-success" : "";
|
|
@@ -570,19 +703,9 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
570
703
|
else {
|
|
571
704
|
displayLines.push("<span class=\"btn-icon-placeholder\"></span>");
|
|
572
705
|
}
|
|
573
|
-
// Position 4:
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-revert\" title=\"Revert to defaults\" aria-label=\"Revert to defaults\"" +
|
|
577
|
-
" onclick=\"revertChannel('" + escapedKey + "')\">" + ICON_REVERT + "</button>");
|
|
578
|
-
}
|
|
579
|
-
else if (isUser && !isPredefined) {
|
|
580
|
-
// User-defined (not an override): delete.
|
|
581
|
-
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete\" aria-label=\"Delete\" onclick=\"deleteChannel('" +
|
|
582
|
-
escapedKey + "')\">" + ICON_DELETE + "</button>");
|
|
583
|
-
}
|
|
584
|
-
else if (isPredefined) {
|
|
585
|
-
// Predefined (no override): enable/disable toggle.
|
|
706
|
+
// Position 4: enable/disable toggle for predefined channels (regardless of override state), delete for user-defined channels. The enable/disable state and
|
|
707
|
+
// property overrides are independent concerns — a predefined channel can be disabled AND have customizations, and the toggle always reflects the visibility state.
|
|
708
|
+
if (isPredefined) {
|
|
586
709
|
if (isDisabled) {
|
|
587
710
|
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-enable\" title=\"Enable\" aria-label=\"Enable\" onclick=\"togglePredefinedChannel('" +
|
|
588
711
|
escapedKey + "', true)\">" + ICON_ENABLE + "</button>");
|
|
@@ -592,6 +715,10 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
592
715
|
escapedKey + "', false)\">" + ICON_DISABLE + "</button>");
|
|
593
716
|
}
|
|
594
717
|
}
|
|
718
|
+
else if (isUser) {
|
|
719
|
+
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete\" aria-label=\"Delete\" onclick=\"deleteChannel('" +
|
|
720
|
+
escapedKey + "')\">" + ICON_DELETE + "</button>");
|
|
721
|
+
}
|
|
595
722
|
// Position 5: Copy URL dropdown (all channels).
|
|
596
723
|
displayLines.push("<div class=\"dropdown copy-dropdown\">");
|
|
597
724
|
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-copy\" title=\"Copy stream URL\" aria-label=\"Copy stream URL\" " +
|
|
@@ -605,8 +732,8 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
605
732
|
displayLines.push("</td>");
|
|
606
733
|
displayLines.push("</tr>");
|
|
607
734
|
const displayRow = displayLines.join("\n");
|
|
608
|
-
// Generate edit form row for all channels. Pre-populate with the currently selected
|
|
609
|
-
// canonical definition which may differ when a
|
|
735
|
+
// Generate edit form row for all channels. Pre-populate with the currently selected service's values so the user sees what they're actually streaming, not the
|
|
736
|
+
// canonical definition which may differ when a service variant is selected.
|
|
610
737
|
const editLines = [];
|
|
611
738
|
editLines.push("<tr id=\"edit-row-" + escapedKey + "\" style=\"display: none;\">");
|
|
612
739
|
editLines.push("<td colspan=\"" + String(TOTAL_COLUMN_COUNT) + "\">");
|
|
@@ -615,30 +742,53 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
615
742
|
editLines.push("<form id=\"edit-channel-form-" + escapedKey + "\" onsubmit=\"return submitChannelForm(event, 'edit')\">");
|
|
616
743
|
editLines.push("<input type=\"hidden\" name=\"action\" value=\"edit\">");
|
|
617
744
|
editLines.push("<input type=\"hidden\" name=\"key\" value=\"" + escapedKey + "\">");
|
|
745
|
+
// For override channels, look up the predefined defaults so each field can show the "modified from defaults" indicator when its value differs. This applies
|
|
746
|
+
// the same visual treatment used by the settings form — border accent, blue dot, and per-field reset button — so users can immediately see which fields they
|
|
747
|
+
// customized and reset individual fields without reverting the entire channel.
|
|
748
|
+
const predefined = isOverride ? PREDEFINED_CHANNELS[key] : undefined;
|
|
749
|
+
const predefinedTags = predefined ? getChannelEffectiveTags(predefined).join(", ") : undefined;
|
|
618
750
|
// Channel name.
|
|
619
751
|
editLines.push(...generateTextField("edit-name-" + key, "name", "Display Name", channel.name ?? key, {
|
|
752
|
+
defaultValue: predefined ? (predefined.name ?? key) : undefined,
|
|
620
753
|
hint: "Friendly name shown in the playlist and UI.",
|
|
621
754
|
required: true
|
|
622
755
|
}));
|
|
623
756
|
// Channel URL.
|
|
624
757
|
editLines.push(...generateTextField("edit-url-" + key, "url", "Stream URL", channel.url, {
|
|
758
|
+
defaultValue: predefined?.url,
|
|
625
759
|
hint: "The URL of the streaming page to capture.",
|
|
626
760
|
required: true,
|
|
627
761
|
type: "url"
|
|
628
762
|
}));
|
|
629
763
|
// Profile dropdown.
|
|
630
|
-
editLines.push(...generateProfileDropdown("edit-profile-" + key, channel.profile ?? "", profiles));
|
|
764
|
+
editLines.push(...generateProfileDropdown("edit-profile-" + key, channel.profile ?? "", profiles, true, isOverride ? (predefined?.profile ?? "") : undefined));
|
|
631
765
|
// Advanced fields.
|
|
632
766
|
editLines.push(...generateAdvancedFields("edit-" + key, {
|
|
633
767
|
channelNumberValue: channel.channelNumber ? String(channel.channelNumber) : "",
|
|
634
768
|
channelSelectorValue: channel.channelSelector ?? "",
|
|
769
|
+
defaults: predefined ? {
|
|
770
|
+
channelNumber: predefined.channelNumber ? String(predefined.channelNumber) : "",
|
|
771
|
+
channelSelector: predefined.channelSelector ?? "",
|
|
772
|
+
guideTitle: predefined.guideTitle ?? "",
|
|
773
|
+
hdhrEnabled: predefined.hdhrEnabled !== false,
|
|
774
|
+
logoUrl: predefined.logoUrl ?? "",
|
|
775
|
+
stationId: predefined.stationId ?? "",
|
|
776
|
+
tags: predefinedTags
|
|
777
|
+
} : undefined,
|
|
778
|
+
guideTitleValue: channel.guideTitle ?? "",
|
|
635
779
|
hdhrEnabled: channel.hdhrEnabled !== false,
|
|
636
|
-
|
|
780
|
+
logoUrlValue: channel.logoUrl ?? "",
|
|
781
|
+
stationIdValue: channel.stationId ?? "",
|
|
782
|
+
tagsValue: effectiveTags.join(", ")
|
|
637
783
|
}));
|
|
638
784
|
// Form buttons.
|
|
639
785
|
editLines.push("<div class=\"form-buttons\">");
|
|
640
786
|
editLines.push("<button type=\"submit\" class=\"btn btn-primary\">Save Changes</button>");
|
|
641
787
|
editLines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"hideEditForm('" + escapedKey + "')\">Cancel</button>");
|
|
788
|
+
if (isOverride) {
|
|
789
|
+
editLines.push("<button type=\"button\" class=\"btn btn-secondary btn-revert\" onclick=\"revertChannel('" + escapedKey +
|
|
790
|
+
"')\">Revert to Defaults</button>");
|
|
791
|
+
}
|
|
642
792
|
editLines.push("</div>");
|
|
643
793
|
editLines.push("</form>");
|
|
644
794
|
editLines.push("</div>");
|
|
@@ -666,7 +816,7 @@ export function buildChannelTableState(listing) {
|
|
|
666
816
|
else {
|
|
667
817
|
predefined++;
|
|
668
818
|
}
|
|
669
|
-
if (!entry.enabled || !entry.
|
|
819
|
+
if (!entry.enabled || !entry.availableByService) {
|
|
670
820
|
disabled++;
|
|
671
821
|
}
|
|
672
822
|
}
|
|
@@ -678,7 +828,7 @@ export function buildChannelTableState(listing) {
|
|
|
678
828
|
};
|
|
679
829
|
}
|
|
680
830
|
/**
|
|
681
|
-
* Counts channels included and excluded from the HDHomeRun lineup. Only counts enabled,
|
|
831
|
+
* Counts channels included and excluded from the HDHomeRun lineup. Only counts enabled, service-available channels (the same set visible in the table).
|
|
682
832
|
* @param listing - The channel listing entries.
|
|
683
833
|
* @returns An object with `enabled` (HDHR-included) and `total` counts.
|
|
684
834
|
*/
|
|
@@ -686,7 +836,7 @@ function getHdhrCounts(listing) {
|
|
|
686
836
|
let enabled = 0;
|
|
687
837
|
let total = 0;
|
|
688
838
|
for (const entry of listing) {
|
|
689
|
-
if (!entry.enabled || !entry.
|
|
839
|
+
if (!entry.enabled || !entry.availableByService) {
|
|
690
840
|
continue;
|
|
691
841
|
}
|
|
692
842
|
total++;
|
|
@@ -696,6 +846,47 @@ function getHdhrCounts(listing) {
|
|
|
696
846
|
}
|
|
697
847
|
return { enabled, total };
|
|
698
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* Computes per-tag channel counts for the Quick Actions tag bulk toggles. For each tag in the active vocabulary, counts how many enabled, service-available
|
|
851
|
+
* channels have that tag in their effective tags. Returns undefined when the tags column is not visible (no tag toggles to update).
|
|
852
|
+
* @param listing - The channel listing entries.
|
|
853
|
+
* @returns A record mapping tag names to { count, total }, or undefined when hidden.
|
|
854
|
+
*/
|
|
855
|
+
function getTagCounts(listing) {
|
|
856
|
+
const visibleCols = new Set(CONFIG.channels.visibleColumns);
|
|
857
|
+
if (!visibleCols.has("tags")) {
|
|
858
|
+
return undefined;
|
|
859
|
+
}
|
|
860
|
+
const vocabulary = getActiveTagVocabulary();
|
|
861
|
+
if (vocabulary.length === 0) {
|
|
862
|
+
return undefined;
|
|
863
|
+
}
|
|
864
|
+
const result = {};
|
|
865
|
+
let total = 0;
|
|
866
|
+
// Pre-compute the vocabulary Set once for the per-channel effective tags filter.
|
|
867
|
+
const vocabularySet = new Set(vocabulary);
|
|
868
|
+
const counts = new Map();
|
|
869
|
+
for (const tag of vocabulary) {
|
|
870
|
+
counts.set(tag, 0);
|
|
871
|
+
}
|
|
872
|
+
for (const entry of listing) {
|
|
873
|
+
if (!entry.enabled || !entry.availableByService) {
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
total++;
|
|
877
|
+
const effectiveTags = entry.channel.tags ? entry.channel.tags.filter((tag) => vocabularySet.has(tag)) : [];
|
|
878
|
+
for (const tag of effectiveTags) {
|
|
879
|
+
const current = counts.get(tag);
|
|
880
|
+
if (current !== undefined) {
|
|
881
|
+
counts.set(tag, current + 1);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
for (const tag of vocabulary) {
|
|
886
|
+
result[tag] = { count: counts.get(tag) ?? 0, total };
|
|
887
|
+
}
|
|
888
|
+
return result;
|
|
889
|
+
}
|
|
699
890
|
/**
|
|
700
891
|
* Builds a complete channel table patch for the given set of affected channel keys. Generates row HTML for keys that exist in the listing (update action) and
|
|
701
892
|
* remove actions for keys that no longer exist. Includes the current summary counts and scope toggle counts. This is the single function mutation endpoints
|
|
@@ -719,25 +910,24 @@ export function buildChannelTablePatch(affectedKeys, profiles) {
|
|
|
719
910
|
}
|
|
720
911
|
}
|
|
721
912
|
const { counts, scopeCounts } = buildChannelTableState(listing);
|
|
722
|
-
return { counts, hdhrCounts: getHdhrCounts(listing), rows, scopeCounts };
|
|
913
|
+
return { counts, hdhrCounts: getHdhrCounts(listing), rows, scopeCounts, tagCounts: getTagCounts(listing) };
|
|
723
914
|
}
|
|
724
915
|
/**
|
|
725
|
-
* Generates the
|
|
726
|
-
* @returns HTML string for the
|
|
916
|
+
* Generates the service filter toolbar HTML with a multi-select dropdown and dismissable chips.
|
|
917
|
+
* @returns HTML string for the service filter toolbar.
|
|
727
918
|
*/
|
|
728
|
-
export function
|
|
729
|
-
const allTags =
|
|
730
|
-
const enabled =
|
|
919
|
+
export function generateServiceFilterToolbar() {
|
|
920
|
+
const allTags = getAllServiceTags();
|
|
921
|
+
const enabled = getEnabledServices();
|
|
731
922
|
const hasFilter = enabled.length > 0;
|
|
732
923
|
const lines = [];
|
|
733
924
|
lines.push("<div class=\"provider-toolbar\">");
|
|
734
|
-
//
|
|
925
|
+
// Service filter dropdown and chips. The button follows the toolbar dropdown pattern: icon + label + caret in a single button.
|
|
735
926
|
lines.push("<div class=\"toolbar-group\">");
|
|
736
|
-
lines.push("<span class=\"toolbar-label\">Providers:</span>");
|
|
737
927
|
lines.push("<div class=\"dropdown provider-dropdown\">");
|
|
738
|
-
const buttonText = hasFilter ? "Filtered" : "All
|
|
739
|
-
lines.push("<button type=\"button\" class=\"btn btn-sm\" id=\"provider-filter-btn\"
|
|
740
|
-
"onclick=\"toggleDropdown(this)\">" + buttonText + " ▾</button>");
|
|
928
|
+
const buttonText = hasFilter ? "Filtered" : "All Services";
|
|
929
|
+
lines.push("<button type=\"button\" class=\"btn btn-sm toolbar-icon-btn\" id=\"provider-filter-btn\" " +
|
|
930
|
+
"title=\"Filter channels by streaming service\" onclick=\"toggleDropdown(this)\">" + ICON_FILTER + " " + buttonText + " ▾</button>");
|
|
741
931
|
lines.push("<div class=\"dropdown-menu provider-dropdown-menu\">");
|
|
742
932
|
for (const tagInfo of allTags) {
|
|
743
933
|
const isDirectTag = tagInfo.tag === "direct";
|
|
@@ -746,7 +936,7 @@ export function generateProviderFilterToolbar() {
|
|
|
746
936
|
const disabledAttr = isDirectTag ? " disabled" : "";
|
|
747
937
|
lines.push("<label class=\"provider-option\">");
|
|
748
938
|
lines.push("<input type=\"checkbox\" data-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + checkedAttr + disabledAttr +
|
|
749
|
-
" onchange=\"
|
|
939
|
+
" onchange=\"toggleServiceTag(this)\"> " + serviceDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl));
|
|
750
940
|
lines.push("</label>");
|
|
751
941
|
}
|
|
752
942
|
lines.push("</div>");
|
|
@@ -761,9 +951,9 @@ export function generateProviderFilterToolbar() {
|
|
|
761
951
|
const chipTag = allTags.find((t) => t.tag === tag);
|
|
762
952
|
const displayName = chipTag?.displayName ?? tag;
|
|
763
953
|
lines.push("<span class=\"provider-chip\" data-tag=\"" + escapeHtml(tag) + "\">" +
|
|
764
|
-
|
|
954
|
+
serviceDisplaySpan(displayName, chipTag?.domain, chipTag?.iconUrl, true) +
|
|
765
955
|
"<button type=\"button\" class=\"chip-close\" title=\"Remove " + escapeHtml(displayName) + " from filter\" aria-label=\"Remove " +
|
|
766
|
-
escapeHtml(displayName) + "\" onclick=\"
|
|
956
|
+
escapeHtml(displayName) + "\" onclick=\"removeServiceChip('" + escapeHtml(tag) + "')\">×</button></span>");
|
|
767
957
|
}
|
|
768
958
|
}
|
|
769
959
|
lines.push("</div>");
|
|
@@ -782,70 +972,68 @@ export function generateProviderFilterToolbar() {
|
|
|
782
972
|
* @returns HTML string for the Channels panel content.
|
|
783
973
|
*/
|
|
784
974
|
export function generateChannelsPanel(channelMessage, channelError, editingChannelKey, showAddForm, formErrors, formValues) {
|
|
785
|
-
// Get the canonical channel listing (
|
|
786
|
-
// it handles predefined/user merging, disabled state, and
|
|
975
|
+
// Get the canonical channel listing (service variants already filtered out, sorted by key). This is the single source of truth for merged channel data —
|
|
976
|
+
// it handles predefined/user merging, disabled state, and service availability.
|
|
787
977
|
const listing = getChannelListing();
|
|
788
978
|
const profiles = getProfiles();
|
|
789
|
-
// Count channels hidden from the default view: disabled predefined channels OR channels with no available
|
|
790
|
-
const totalHiddenCount = listing.filter((entry) => !entry.enabled || !entry.
|
|
979
|
+
// Count channels hidden from the default view: disabled predefined channels OR channels with no available services.
|
|
980
|
+
const totalHiddenCount = listing.filter((entry) => !entry.enabled || !entry.availableByService).length;
|
|
791
981
|
const lines = [];
|
|
792
982
|
// Panel description.
|
|
793
983
|
lines.push("<div class=\"settings-panel-description\">");
|
|
794
984
|
lines.push("<p>Define and manage streaming channels for the playlist. Customized channels are highlighted.</p>");
|
|
795
|
-
lines.push("<p class=\"description-hint\">Tip: Use the <strong>
|
|
985
|
+
lines.push("<p class=\"description-hint\">Tip: Use the <strong>service filter</strong> above to show only channels from services you subscribe to — ", "this also controls which channels Channels DVR sees in the playlist. Use the <strong>service dropdown</strong> on any multi-service channel to choose ", "which streaming service delivers it (e.g., Comedy Central via Hulu vs Sling). Click the <strong>edit icon</strong> to customize any channel's name, ", "Gracenote station ID, URL, or other properties.</p>");
|
|
796
986
|
lines.push("</div>");
|
|
797
987
|
// Toolbar with three dropdown menus: Manage Channels (primary actions), Import/Export (data I/O), and Quick Actions (bulk operations). Each dropdown has an
|
|
798
988
|
// inline SVG icon + label + chevron for visual discoverability. Grouped menus reduce visual clutter and separate channel management from data I/O and bulk
|
|
799
989
|
// operations.
|
|
800
|
-
// Toolbar-level SVG icons (14x14, stroke-based, currentColor). Defined here since they're toolbar-specific and not reused elsewhere.
|
|
801
|
-
const ICON_MANAGE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
|
|
802
|
-
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"1.5\" y=\"5\" width=\"13\" height=\"9.5\" rx=\"1.5\"/>" +
|
|
803
|
-
"<path d=\"M7 4.5L3.5 1\"/><path d=\"M9 4.5L12.5 1\"/></svg>";
|
|
804
|
-
const ICON_TRANSFER = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
|
|
805
|
-
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 5h10M10 2l3 3-3 3\"/><path d=\"M13 11H3M6 8l-3 3 3 3\"/></svg>";
|
|
806
|
-
const ICON_BOLT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
|
|
807
|
-
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 1.5L4 9h4l-1 5.5L12 7H8l1-5.5z\"/></svg>";
|
|
808
990
|
lines.push("<div class=\"channel-toolbar\">");
|
|
809
991
|
lines.push("<div class=\"toolbar-group\">");
|
|
810
992
|
// Manage Channels dropdown — primary channel creation and setup actions.
|
|
811
993
|
lines.push("<div class=\"dropdown\">");
|
|
812
|
-
lines.push("<button type=\"button\" class=\"btn btn-primary btn-sm toolbar-
|
|
994
|
+
lines.push("<button type=\"button\" class=\"btn btn-primary btn-sm toolbar-icon-btn\" title=\"Add, browse, or set up channels\" " +
|
|
813
995
|
"onclick=\"toggleDropdown(this)\">" + ICON_MANAGE + " Manage Channels ▾</button>");
|
|
814
996
|
lines.push("<div class=\"dropdown-menu\">");
|
|
815
|
-
// Dropdown item icons
|
|
816
|
-
|
|
997
|
+
// Dropdown item icons: tinted variants of the shared icons for visual differentiation in menu items. Each uses a distinct color so items are scannable at
|
|
998
|
+
// a glance. These are local to this function because the tint colors are context-specific to this dropdown.
|
|
999
|
+
const TINTED_ADD = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#22a563\" stroke-width=\"1.5\" " +
|
|
817
1000
|
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 3v10M3 8h10\"/></svg>";
|
|
818
|
-
const
|
|
1001
|
+
const TINTED_BROWSE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#5b8def\" stroke-width=\"1.5\" " +
|
|
819
1002
|
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"2\" width=\"5\" height=\"5\" rx=\"1\"/><rect x=\"9\" y=\"2\" width=\"5\" " +
|
|
820
1003
|
"height=\"5\" rx=\"1\"/><rect x=\"2\" y=\"9\" width=\"5\" height=\"5\" rx=\"1\"/><rect x=\"9\" y=\"9\" width=\"5\" height=\"5\" rx=\"1\"/></svg>";
|
|
821
|
-
const
|
|
1004
|
+
const TINTED_SETUP = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#9b8cd8\" stroke-width=\"1.5\" " +
|
|
822
1005
|
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"2.5\"/><path d=\"M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.1 " +
|
|
823
1006
|
"3.1l1.4 1.4M11.5 11.5l1.4 1.4M3.1 12.9l1.4-1.4M11.5 4.5l1.4-1.4\"/></svg>";
|
|
824
|
-
|
|
825
|
-
"
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1007
|
+
const TINTED_TAG = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#9b59b6\" stroke-width=\"1.5\" " +
|
|
1008
|
+
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M1.5 8.7V2.5a1 1 0 011-1h6.2a1 1 0 01.7.3l5.1 5.1a1 1 0 010 1.4l-5.5 5.5a1 " +
|
|
1009
|
+
"1 0 01-1.4 0L1.8 9.4a1 1 0 01-.3-.7z\"/><circle cx=\"5\" cy=\"5\" r=\"1\"/></svg>";
|
|
1010
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); " +
|
|
1011
|
+
"document.getElementById('add-channel-form').style.display='block';\">" + TINTED_ADD + " Add Channel</div>");
|
|
1012
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openBrowseModal()\">" + TINTED_BROWSE +
|
|
1013
|
+
" Browse Service Channels</div>");
|
|
1014
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openTagManager()\">" + TINTED_TAG +
|
|
1015
|
+
" Manage Tags</div>");
|
|
1016
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openSetupWizard()\">" + TINTED_SETUP +
|
|
1017
|
+
" Service Setup</div>");
|
|
830
1018
|
lines.push("</div>");
|
|
831
1019
|
lines.push("</div>");
|
|
832
1020
|
// Import / Export dropdown — data I/O operations.
|
|
833
1021
|
lines.push("<div class=\"dropdown\">");
|
|
834
|
-
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-
|
|
1022
|
+
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-icon-btn\" title=\"Import or export channel data\" " +
|
|
835
1023
|
"onclick=\"toggleDropdown(this)\">" + ICON_TRANSFER + " Import / Export ▾</button>");
|
|
836
1024
|
lines.push("<div class=\"dropdown-menu\">");
|
|
837
|
-
const
|
|
1025
|
+
const TINTED_IMPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#5b8def\" stroke-width=\"1.5\" " +
|
|
838
1026
|
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 2v8M5 7l3 3 3-3\"/><path d=\"M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2\"/></svg>";
|
|
839
|
-
const
|
|
1027
|
+
const TINTED_EXPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#22a563\" stroke-width=\"1.5\" " +
|
|
840
1028
|
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 10V2M5 5l3-3 3 3\"/><path d=\"M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2\"/></svg>";
|
|
841
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
842
|
-
|
|
843
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
844
|
-
|
|
1029
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); document.getElementById('import-channels-file').click()\">" +
|
|
1030
|
+
TINTED_IMPORT + " Import Channels (JSON)</div>");
|
|
1031
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); document.getElementById('import-m3u-file').click()\">" +
|
|
1032
|
+
TINTED_IMPORT + " Import M3U Playlist</div>");
|
|
845
1033
|
lines.push("<label class=\"dropdown-option\"><input type=\"checkbox\" id=\"m3u-replace-duplicates\"> Replace duplicates on M3U import</label>");
|
|
846
1034
|
lines.push("<div class=\"dropdown-divider\"></div>");
|
|
847
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
848
|
-
|
|
1035
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); exportChannels()\">" +
|
|
1036
|
+
TINTED_EXPORT + " Export Channels (JSON)</div>");
|
|
849
1037
|
lines.push("</div>");
|
|
850
1038
|
lines.push("</div>");
|
|
851
1039
|
lines.push("<input type=\"file\" id=\"import-m3u-file\" accept=\".m3u,.m3u8\" style=\"display: none;\" onchange=\"importM3U(this)\">");
|
|
@@ -853,7 +1041,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
853
1041
|
const visibleCols = new Set(CONFIG.channels.visibleColumns);
|
|
854
1042
|
// Quick Actions dropdown — bulk operations for predefined channels.
|
|
855
1043
|
lines.push("<div class=\"dropdown quick-actions-dropdown\">");
|
|
856
|
-
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-
|
|
1044
|
+
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-icon-btn\" title=\"Bulk operations for predefined channels\" " +
|
|
857
1045
|
"onclick=\"toggleDropdown(this)\">" + ICON_BOLT + " Quick Actions ▾</button>");
|
|
858
1046
|
lines.push("<div class=\"dropdown-menu\">");
|
|
859
1047
|
// Compute initial toggle counts for predefined channel scopes. The server is the single source of truth — the client renders what we return here.
|
|
@@ -873,15 +1061,15 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
873
1061
|
"\">" + String(s.count) + " of " + String(s.total) + " enabled</span></label>");
|
|
874
1062
|
}
|
|
875
1063
|
lines.push("<div class=\"dropdown-divider\"></div>");
|
|
876
|
-
// Bulk assign select — switch all multi-
|
|
877
|
-
const allTags =
|
|
878
|
-
const enabled =
|
|
1064
|
+
// Bulk assign select — switch all multi-service channels to a single service.
|
|
1065
|
+
const allTags = getAllServiceTags();
|
|
1066
|
+
const enabled = getEnabledServices();
|
|
879
1067
|
const hasFilter = enabled.length > 0;
|
|
880
1068
|
lines.push("<div class=\"bulk-assign-row\">");
|
|
881
1069
|
lines.push("<span>Set all channels to:</span>");
|
|
882
|
-
lines.push("<select id=\"bulk-assign-select\" class=\"bulk-assign-select\" title=\"Switch all multi-
|
|
883
|
-
"onchange=\"if(this.value) {
|
|
884
|
-
lines.push("<option value=\"\">Select
|
|
1070
|
+
lines.push("<select id=\"bulk-assign-select\" class=\"bulk-assign-select\" title=\"Switch all multi-service channels to this service\" " +
|
|
1071
|
+
"onchange=\"if(this.value) { dropdowns.close(); bulkAssignService(this.value); this.value = ''; }\">");
|
|
1072
|
+
lines.push("<option value=\"\">Select service</option>");
|
|
885
1073
|
for (const tagInfo of allTags) {
|
|
886
1074
|
const hidden = (hasFilter && !enabled.includes(tagInfo.tag) && (tagInfo.tag !== "direct")) ? " hidden" : "";
|
|
887
1075
|
lines.push("<option value=\"" + escapeHtml(tagInfo.tag) + "\" data-provider-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + hidden + ">" +
|
|
@@ -899,7 +1087,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
899
1087
|
lines.push("<span>Auto-number from:</span>");
|
|
900
1088
|
lines.push("<input type=\"number\" id=\"auto-number-start\" class=\"auto-number-input\" value=\"1\" min=\"0\" max=\"99999\" " +
|
|
901
1089
|
"placeholder=\"Clear\" onclick=\"event.stopPropagation()\">");
|
|
902
|
-
lines.push("<button type=\"button\" class=\"btn btn-sm btn-secondary\" onclick=\"
|
|
1090
|
+
lines.push("<button type=\"button\" class=\"btn btn-sm btn-secondary\" onclick=\"dropdowns.close(); autoNumberChannels()\">Apply</button>");
|
|
903
1091
|
lines.push("</div>");
|
|
904
1092
|
lines.push("</div>");
|
|
905
1093
|
// HDHR bulk toggle — tri-state checkbox to enable/disable all channels for the HDHomeRun lineup. Visible when the HDHR column is shown.
|
|
@@ -914,6 +1102,43 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
914
1102
|
lines.push("<span class=\"quick-action-count\" id=\"hdhr-bulk-count\">" + String(hdhrCounts.enabled) + " of " + String(hdhrCounts.total) + "</span>");
|
|
915
1103
|
lines.push("</label>");
|
|
916
1104
|
lines.push("</div>");
|
|
1105
|
+
// Bulk tag toggles — tristate checkboxes for each tag in the active vocabulary. Visible when the tags column is shown. Each checkbox shows how many enabled
|
|
1106
|
+
// channels have the tag, and clicking it adds or removes the tag on all visible channels.
|
|
1107
|
+
const tagsVisible = visibleCols.has("tags") ? "" : " style=\"display: none;\"";
|
|
1108
|
+
const tagsVocabulary = getActiveTagVocabulary();
|
|
1109
|
+
if (tagsVocabulary.length > 0) {
|
|
1110
|
+
// Count how many enabled, service-available channels have each tag.
|
|
1111
|
+
const enabledListing = listing.filter((entry) => entry.enabled && entry.availableByService);
|
|
1112
|
+
const totalEnabled = enabledListing.length;
|
|
1113
|
+
const tagCountMap = new Map();
|
|
1114
|
+
for (const tag of tagsVocabulary) {
|
|
1115
|
+
tagCountMap.set(tag, 0);
|
|
1116
|
+
}
|
|
1117
|
+
for (const entry of enabledListing) {
|
|
1118
|
+
const effectiveTags = getChannelEffectiveTags(entry.channel);
|
|
1119
|
+
for (const tag of effectiveTags) {
|
|
1120
|
+
const current = tagCountMap.get(tag);
|
|
1121
|
+
if (current !== undefined) {
|
|
1122
|
+
tagCountMap.set(tag, current + 1);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
lines.push("<div class=\"dropdown-divider\" id=\"quick-action-tags-divider\"" + tagsVisible + "></div>");
|
|
1127
|
+
lines.push("<div id=\"quick-action-tags\"" + tagsVisible + ">");
|
|
1128
|
+
lines.push("<div class=\"quick-action-section-label\">Tag Channels</div>");
|
|
1129
|
+
for (const tag of tagsVocabulary) {
|
|
1130
|
+
const count = tagCountMap.get(tag) ?? 0;
|
|
1131
|
+
const checkedAttr = (count === totalEnabled) ? " checked" : "";
|
|
1132
|
+
const indeterminate = ((count > 0) && (count < totalEnabled)) ? " data-indeterminate=\"true\"" : "";
|
|
1133
|
+
lines.push("<label class=\"provider-option\" onclick=\"event.preventDefault(); bulkToggleTag('" + escapeHtml(tag) +
|
|
1134
|
+
"', !this.querySelector('input').checked)\">" +
|
|
1135
|
+
"<input type=\"checkbox\" class=\"tag-bulk-toggle\" data-tag=\"" + escapeHtml(tag) + "\"" + checkedAttr + indeterminate + "> " +
|
|
1136
|
+
escapeHtml(tag) +
|
|
1137
|
+
"<span class=\"quick-action-count\" data-tag-count=\"" + escapeHtml(tag) + "\">" +
|
|
1138
|
+
String(count) + " of " + String(totalEnabled) + "</span></label>");
|
|
1139
|
+
}
|
|
1140
|
+
lines.push("</div>");
|
|
1141
|
+
}
|
|
917
1142
|
lines.push("</div>");
|
|
918
1143
|
lines.push("</div>");
|
|
919
1144
|
lines.push("</div>");
|
|
@@ -962,12 +1187,12 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
962
1187
|
lines.push("<h3>Add New Channel</h3>");
|
|
963
1188
|
lines.push("<form id=\"add-channel-form-el\" onsubmit=\"return submitChannelForm(event, 'add')\">");
|
|
964
1189
|
lines.push("<input type=\"hidden\" name=\"action\" value=\"add\">");
|
|
965
|
-
//
|
|
966
|
-
// resolution). The pills use
|
|
967
|
-
// guideUrl; non-guide-grid
|
|
968
|
-
const addFormTags =
|
|
1190
|
+
// Service pills. Clicking a service auto-fills the URL field, which triggers the existing URL-change infrastructure (datalist population, profile
|
|
1191
|
+
// resolution). The pills use serviceDisplaySpan for icon rendering via the shared processServiceDisplays path. Guide-grid services fill with their
|
|
1192
|
+
// guideUrl; non-guide-grid services fill with https://{domain}/.
|
|
1193
|
+
const addFormTags = getAllServiceTags();
|
|
969
1194
|
const addFormGuideUrls = getProviderGuideUrls();
|
|
970
|
-
lines.push("<div class=\"form-row\"><label>
|
|
1195
|
+
lines.push("<div class=\"form-row\"><label>Service</label>");
|
|
971
1196
|
lines.push("<div class=\"provider-pills\">");
|
|
972
1197
|
for (const tagInfo of addFormTags) {
|
|
973
1198
|
if (tagInfo.tag === "direct") {
|
|
@@ -975,11 +1200,11 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
975
1200
|
}
|
|
976
1201
|
const pillUrl = addFormGuideUrls[tagInfo.tag] ?? (tagInfo.domain ? "https://" + tagInfo.domain + "/" : "");
|
|
977
1202
|
lines.push("<button type=\"button\" class=\"provider-pill\" data-slug=\"" + escapeHtml(tagInfo.tag) + "\" data-url=\"" + escapeHtml(pillUrl) +
|
|
978
|
-
"\" onclick=\"
|
|
1203
|
+
"\" onclick=\"selectServicePill(this)\">" + serviceDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl) + "</button>");
|
|
979
1204
|
}
|
|
980
1205
|
lines.push("</div>");
|
|
981
1206
|
lines.push("</div>");
|
|
982
|
-
lines.push("<div class=\"hint\">Select a
|
|
1207
|
+
lines.push("<div class=\"hint\">Select a service to auto-fill the URL, or enter one manually below.</div>");
|
|
983
1208
|
// Channel key (add form only).
|
|
984
1209
|
lines.push(...generateTextField("add-key", "key", "Channel Key", formValues?.get("key") ?? "", {
|
|
985
1210
|
hint: "Lowercase letters, numbers, and hyphens only. Used in the URL: /stream/channel-key",
|
|
@@ -1000,9 +1225,12 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1000
1225
|
required: true,
|
|
1001
1226
|
type: "url"
|
|
1002
1227
|
}));
|
|
1228
|
+
// Inline hint for predefined channel matches. Hidden by default, shown by the URL field change listener in config.ts when the entered URL's domain matches a
|
|
1229
|
+
// predefined channel. This extends the form's existing URL-based intelligence (channelSelector suggestions, stationId auto-fill, profile auto-detection).
|
|
1230
|
+
lines.push("<div id=\"add-predefined-hint\" class=\"hint predefined-hint\" style=\"display: none;\"></div>");
|
|
1003
1231
|
// Profile dropdown.
|
|
1004
1232
|
lines.push(...generateProfileDropdown("add-profile", formValues?.get("profile") ?? "", profiles));
|
|
1005
|
-
// Advanced fields (station ID, channel selector, channel number, HDHR).
|
|
1233
|
+
// Advanced fields (station ID, guide title, logo URL, channel selector, channel number, tags, HDHR).
|
|
1006
1234
|
lines.push(...generateAdvancedFields("add", {
|
|
1007
1235
|
channelNumberValue: formValues?.get("channelNumber") ?? "",
|
|
1008
1236
|
channelSelectorValue: formValues?.get("channelSelector") ?? "",
|
|
@@ -1028,8 +1256,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1028
1256
|
}
|
|
1029
1257
|
const sortField = CONFIG.channels.channelSortField;
|
|
1030
1258
|
const sortDir = CONFIG.channels.channelSortDirection;
|
|
1031
|
-
//
|
|
1032
|
-
lines.push(
|
|
1259
|
+
// Service filter toolbar with multi-select dropdown and chips.
|
|
1260
|
+
lines.push(generateServiceFilterToolbar());
|
|
1033
1261
|
// Channel summary line with predefined/user breakdown, placed directly above the table for visual proximity. The user-count span contains the entire user
|
|
1034
1262
|
// portion (comma, count, and label) so the client can toggle it by setting textContent. When there are no user channels, the span is empty.
|
|
1035
1263
|
const userPortion = (userCount > 0) ? ", " + String(userCount) + " user" : "";
|
|
@@ -1047,14 +1275,37 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1047
1275
|
const sortableHeaders = [
|
|
1048
1276
|
{ cssClass: "col-key", field: "key", label: "Key" },
|
|
1049
1277
|
{ cssClass: "col-name", field: "name", label: "Name" },
|
|
1050
|
-
{ cssClass: "col-provider", field: "provider", label: "
|
|
1278
|
+
{ cssClass: "col-provider", field: "provider", label: "Service" },
|
|
1051
1279
|
...OPTIONAL_COLUMNS
|
|
1052
1280
|
];
|
|
1281
|
+
// All sortable headers use the same DOM structure: <th onclick> wraps a <span class="sort-label"> for the label text. The sort update logic targets
|
|
1282
|
+
// .sort-label to modify only the label — never touching other children like the Tags filter dropdown. Clicking anywhere on the <th> triggers sort;
|
|
1283
|
+
// additional children (like the filter button) use event.stopPropagation() to prevent sort when interacting with them.
|
|
1053
1284
|
for (const hdr of sortableHeaders) {
|
|
1054
1285
|
const isActive = (sortField === hdr.field);
|
|
1055
|
-
const
|
|
1056
|
-
lines.push("<th class=\"" + hdr.cssClass + " sortable\" data-sort-field=\"" + hdr.field +
|
|
1057
|
-
|
|
1286
|
+
const activeIndicator = isActive ? ((sortDir === "asc") ? " ▲" : " ▼") : "";
|
|
1287
|
+
lines.push("<th class=\"" + hdr.cssClass + " sortable\" data-sort-field=\"" + hdr.field +
|
|
1288
|
+
"\" onclick=\"channelTable.sort('" + hdr.field + "')\">");
|
|
1289
|
+
lines.push("<span class=\"sort-label\">" + hdr.label + activeIndicator + "</span>");
|
|
1290
|
+
// Tags header: additional filter dropdown alongside the sort label. The dropdown is a client-side-only view filter (transient, not persisted) that
|
|
1291
|
+
// shows/hides rows based on their data-channel-tags attribute. The dropdown content comes from generateTagFilterContent() — the single source of truth
|
|
1292
|
+
// shared with the tag CRUD incremental update path.
|
|
1293
|
+
if (hdr.field === "tags") {
|
|
1294
|
+
lines.push("<div class=\"dropdown tag-filter-dropdown\" style=\"display: inline;\">");
|
|
1295
|
+
lines.push("<button type=\"button\" class=\"btn-icon btn-tag-filter\" title=\"Filter by tag\" " +
|
|
1296
|
+
"onclick=\"event.stopPropagation(); toggleDropdown(this)\">" + ICON_FILTER + "</button>");
|
|
1297
|
+
lines.push("<div class=\"dropdown-menu\" id=\"tag-filter-menu\">" + generateTagFilterContent() + "</div>");
|
|
1298
|
+
lines.push("</div>");
|
|
1299
|
+
// Playlist hint icon. Hidden by default, shown by applyTagColumnFilter() when the filter is active. Clicking opens a popover with the playlist URL
|
|
1300
|
+
// that corresponds to the current tag filter, so users can copy it for Channels DVR configuration.
|
|
1301
|
+
lines.push("<div class=\"dropdown\" style=\"display: inline;\">");
|
|
1302
|
+
lines.push("<button type=\"button\" class=\"btn-icon btn-playlist-hint\" id=\"playlist-hint-btn\" " +
|
|
1303
|
+
"title=\"Playlist URL for this filter\" style=\"display: none;\" " +
|
|
1304
|
+
"onclick=\"event.stopPropagation(); showPlaylistHint(this)\">" + ICON_LINK + "</button>");
|
|
1305
|
+
lines.push("<div class=\"dropdown-menu playlist-hint-menu\"></div>");
|
|
1306
|
+
lines.push("</div>");
|
|
1307
|
+
}
|
|
1308
|
+
lines.push("</th>");
|
|
1058
1309
|
}
|
|
1059
1310
|
// Actions header with table options dropdown (kebab menu). Contains the show-disabled toggle and column visibility checkboxes.
|
|
1060
1311
|
lines.push("<th class=\"col-actions\"><span>Actions</span>");
|
|
@@ -1077,7 +1328,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1077
1328
|
lines.push("</thead>");
|
|
1078
1329
|
lines.push("<tbody>");
|
|
1079
1330
|
// Sort the listing by the user's preferred field and direction before rendering rows. The canonical getChannelListing() order is preserved for other callers.
|
|
1080
|
-
const sortedListing =
|
|
1331
|
+
const sortedListing = listing.toSorted((a, b) => compareChannelSort(a.channel, a.key, b.channel, b.key, sortField, sortDir));
|
|
1081
1332
|
// Generate rows for all channels using the shared row generator.
|
|
1082
1333
|
for (const entry of sortedListing) {
|
|
1083
1334
|
const rowHtml = generateChannelRowHtml(entry.key, profiles, entry);
|
|
@@ -1090,12 +1341,24 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1090
1341
|
// Embed channel selector data for datalist population. The client-side JavaScript uses this to offer known selector suggestions when the URL matches a
|
|
1091
1342
|
// multi-channel site like Disney+ or USA Network.
|
|
1092
1343
|
lines.push("<script>" + generateChannelSelectorData() + "</script>");
|
|
1093
|
-
// Browse Channels modal. The shell is server-rendered using wizard modal CSS classes. The content area (
|
|
1094
|
-
// client-side after fetching discovered channels from the
|
|
1344
|
+
// Browse Channels modal. The shell is server-rendered using wizard modal CSS classes. The content area (service picker, channel list) is rendered
|
|
1345
|
+
// client-side after fetching discovered channels from the services endpoint.
|
|
1095
1346
|
lines.push(generateBrowseModal());
|
|
1096
|
-
//
|
|
1347
|
+
// Service Setup wizard modal. Follows the same wizard pattern as the service profile builder. Three steps: pick services, authenticate, browse channels.
|
|
1097
1348
|
// The setupCompleted flag is embedded as a data attribute so the client can auto-show the wizard on first visit.
|
|
1098
1349
|
lines.push(generateSetupWizardModal());
|
|
1350
|
+
// Tag Management modal. A simple dialog for creating, deleting, and restoring organizational tags. Client-side handlers drive the CRUD operations via the
|
|
1351
|
+
// tag management API endpoints.
|
|
1352
|
+
lines.push(generateTagManagementModal());
|
|
1353
|
+
// Inline tag edit portal — a shared dropdown portaled to <body> on first use by getTagPortal() in config.ts. One instance shared across all channel rows;
|
|
1354
|
+
// populated dynamically from the clicked cell's data-tags attribute. Positioned via getBoundingClientRect() to escape the table wrapper's overflow:auto clipping.
|
|
1355
|
+
const vocabulary = getActiveTagVocabulary();
|
|
1356
|
+
lines.push("<div id=\"inline-tag-portal\" class=\"dropdown-menu inline-tag-menu\">");
|
|
1357
|
+
for (const tag of vocabulary) {
|
|
1358
|
+
lines.push("<label class=\"provider-option\" onclick=\"event.stopPropagation()\">" +
|
|
1359
|
+
"<input type=\"checkbox\" class=\"inline-tag-checkbox\" data-tag=\"" + escapeHtml(tag) + "\"> " + escapeHtml(tag) + "</label>");
|
|
1360
|
+
}
|
|
1361
|
+
lines.push("</div>");
|
|
1099
1362
|
return lines.join("\n");
|
|
1100
1363
|
}
|
|
1101
1364
|
//# sourceMappingURL=table.js.map
|