prismcast 1.8.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 +15 -5
- 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/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 +1 -1
- package/dist/channels/index.js +416 -416
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.d.ts +3 -3
- package/dist/config/health.js +3 -3
- package/dist/config/index.js +2 -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/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/{providers.js → services.js} +277 -210
- 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 +36 -27
- package/dist/config/userChannels.js +300 -212
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +11 -9
- package/dist/config/userConfig.js +53 -60
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +31 -15
- package/dist/config/userProfiles.js +139 -96
- 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/index.js +6 -9
- package/dist/hdhr/index.js.map +1 -1
- package/dist/native/index.js +3 -3
- 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.js +4 -4
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +3 -3
- 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/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 +488 -460
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +4 -4
- package/dist/routes/config/channels/table.js +206 -147
- 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} +44 -39
- package/dist/routes/config/services.js.map +1 -0
- package/dist/routes/config/settings.js +47 -29
- 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/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 +4 -4
- package/dist/routes/playlist.js +56 -44
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/root/content.js +58 -58
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +529 -572
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +838 -992
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/shared.js +431 -197
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.js +247 -196
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +12 -4
- 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.js +2 -0
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +5 -8
- 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/hls.d.ts +4 -4
- package/dist/streaming/hls.js +24 -23
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.js +1 -1
- 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.js +1 -1
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/registry.js +4 -8
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +2 -2
- package/dist/streaming/setup.js +23 -22
- 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 +15 -11
- package/dist/types/config.d.ts +2 -2
- package/dist/types/index.d.ts +2 -2
- 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 +1 -1
- package/dist/utils/chromeFetch.js +1 -1
- 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 +7 -1
- package/dist/utils/format.js +21 -1
- package/dist/utils/format.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/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.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
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* table.ts: Channel table rendering for the PrismCast configuration interface.
|
|
4
4
|
*/
|
|
5
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,
|
|
6
|
+
import { compareChannelSort, getAllServiceTags, getAuthDomainForChannel, getChannelServiceLabel, getChannelServiceTags, getChannelSortKey, getEnabledServices, getPredefinedDomainMap, getServiceGroup, hasMultipleServices, isChannelAvailableByService, isServiceTagEnabled, resolvePredefinedVariant, resolveServiceKey } from "../../../config/services.js";
|
|
7
7
|
import { escapeHtml, formatTimeAgo } from "../../../utils/index.js";
|
|
8
|
-
import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getChannelLogo, getChannelsParseErrorMessage, getPredefinedScopeCounts, getTagRegistry, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel } from "../../../config/userChannels.js";
|
|
8
|
+
import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getChannelLogo, getChannelsParseErrorMessage, getPredefinedScopeCounts, getTagRegistry, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel, tagsMatch } from "../../../config/userChannels.js";
|
|
9
9
|
import { getCachedProviderChannels, getProviderDomainMap, getProviderGuideUrls, getProviderModuleInfo } from "../../../browser/channelSelection.js";
|
|
10
10
|
import { getChannelHealth, getDomainAuth } from "../../../config/health.js";
|
|
11
11
|
import { getProfileForChannel, getProfiles } from "../../../config/profiles.js";
|
|
@@ -14,22 +14,22 @@ import { PREDEFINED_CHANNELS } from "../../../channels/index.js";
|
|
|
14
14
|
import { categorizeProfiles } from "../index.js";
|
|
15
15
|
import { generateWizardModal } from "../../components.js";
|
|
16
16
|
/**
|
|
17
|
-
* Generates an annotated
|
|
18
|
-
* icon + text combination. The server just emits the text with data attributes — all icon rendering is client-side through the single
|
|
19
|
-
* @param name - The
|
|
20
|
-
* @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.
|
|
21
21
|
* @param iconUrl - Optional explicit icon URL to try before domain-derived fallbacks.
|
|
22
22
|
* @param small - When true, uses the small icon variant for chips.
|
|
23
23
|
* @returns HTML span string with data attributes for client-side processing.
|
|
24
24
|
*/
|
|
25
|
-
function
|
|
25
|
+
function serviceDisplaySpan(name, domain, iconUrl, small) {
|
|
26
26
|
const domainAttr = domain ? " data-domain=\"" + escapeHtml(domain) + "\"" : "";
|
|
27
27
|
const iconAttr = iconUrl ? " data-icon-url=\"" + escapeHtml(iconUrl) + "\"" : "";
|
|
28
28
|
const sizeAttr = small ? " data-sm" : "";
|
|
29
29
|
return "<span class=\"provider-display\"" + domainAttr + iconAttr + sizeAttr + ">" + escapeHtml(name) + "</span>";
|
|
30
30
|
}
|
|
31
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
|
|
32
|
-
// column order in the table, slotted between
|
|
32
|
+
// column order in the table, slotted between Service and Actions.
|
|
33
33
|
export const OPTIONAL_COLUMNS = [
|
|
34
34
|
{ align: "center", cssClass: "col-chnum", field: "channelNumber", label: "Number", width: "70px" },
|
|
35
35
|
{ align: "center", cssClass: "col-hdhr", field: "hdhrEnabled", label: "HDHR", width: "55px" },
|
|
@@ -58,10 +58,17 @@ function generateTextField(id, name, label, value, options = {}) {
|
|
|
58
58
|
const required = options.required ? " required" : "";
|
|
59
59
|
const pattern = options.pattern ? " pattern=\"" + options.pattern + "\"" : "";
|
|
60
60
|
const placeholder = options.placeholder ? " placeholder=\"" + escapeHtml(options.placeholder) + "\"" : "";
|
|
61
|
-
|
|
62
|
-
|
|
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>");
|
|
63
70
|
lines.push("<input class=\"form-input\" type=\"" + inputType + "\" id=\"" + id + "\" name=\"" + name + "\"" + required + listAttr + pattern +
|
|
64
|
-
placeholder + " value=\"" + escapeHtml(value) + "\">");
|
|
71
|
+
placeholder + defaultAttr + " value=\"" + escapeHtml(value) + "\">" + resetBtn);
|
|
65
72
|
lines.push("</div>");
|
|
66
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
|
|
67
74
|
// based on the URL field value.
|
|
@@ -81,9 +88,17 @@ function generateTextField(id, name, label, value, options = {}) {
|
|
|
81
88
|
* @param showHint - Whether to show the hint text with profile reference link.
|
|
82
89
|
* @returns Array of HTML strings for the form row.
|
|
83
90
|
*/
|
|
84
|
-
function generateProfileDropdown(id, selectedProfile, profiles, showHint = true) {
|
|
91
|
+
function generateProfileDropdown(id, selectedProfile, profiles, showHint = true, defaultProfile) {
|
|
85
92
|
const lines = [];
|
|
86
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>" : "";
|
|
87
102
|
// Helper to generate option elements for a profile.
|
|
88
103
|
const renderOption = (profile) => {
|
|
89
104
|
const selected = (profile.name === selectedProfile) ? " selected" : "";
|
|
@@ -91,9 +106,9 @@ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true)
|
|
|
91
106
|
const displayText = profile.summary ? profile.name + " \u2014 " + profile.summary : profile.name;
|
|
92
107
|
return "<option value=\"" + escapeHtml(profile.name) + "\"" + title + selected + ">" + escapeHtml(displayText) + "</option>";
|
|
93
108
|
};
|
|
94
|
-
lines.push("<div class=\"form-row\">");
|
|
95
|
-
lines.push("<label for=\"" + id + "\">Profile</label>");
|
|
96
|
-
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 + ">");
|
|
97
112
|
lines.push("<option value=\"\">Autodetect (Recommended)</option>");
|
|
98
113
|
// Fullscreen API profiles (most common).
|
|
99
114
|
if (groups.api.length > 0) {
|
|
@@ -135,7 +150,7 @@ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true)
|
|
|
135
150
|
}
|
|
136
151
|
lines.push("</optgroup>");
|
|
137
152
|
}
|
|
138
|
-
lines.push("</select>");
|
|
153
|
+
lines.push("</select>" + resetBtn);
|
|
139
154
|
lines.push("</div>");
|
|
140
155
|
if (showHint) {
|
|
141
156
|
lines.push("<div class=\"hint\">Autodetect uses predefined profiles for known sites. If video doesn't play or fullscreen fails, " +
|
|
@@ -248,7 +263,7 @@ function generateProfileReference(profiles) {
|
|
|
248
263
|
if (groups.custom.length > 0) {
|
|
249
264
|
lines.push("<div class=\"profile-category\">");
|
|
250
265
|
lines.push("<h4>Custom Profiles</h4>");
|
|
251
|
-
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>");
|
|
252
267
|
lines.push("<dl class=\"profile-list\">");
|
|
253
268
|
for (const profile of groups.custom) {
|
|
254
269
|
lines.push("<dt>" + escapeHtml(profile.name) + "</dt>");
|
|
@@ -261,7 +276,7 @@ function generateProfileReference(profiles) {
|
|
|
261
276
|
return lines.join("\n");
|
|
262
277
|
}
|
|
263
278
|
function generateAdvancedFields(idPrefix, options = {}) {
|
|
264
|
-
const { channelNumberValue = "", channelSelectorValue = "", hdhrEnabled = true, showHints = true, stationIdValue = "", tagsValue = "" } = options;
|
|
279
|
+
const { channelNumberValue = "", channelSelectorValue = "", guideTitleValue = "", hdhrEnabled = true, logoUrlValue = "", showHints = true, stationIdValue = "", tagsValue = "" } = options;
|
|
265
280
|
const lines = [];
|
|
266
281
|
// Advanced fields toggle.
|
|
267
282
|
lines.push("<div class=\"advanced-toggle\" onclick=\"document.getElementById('" + idPrefix +
|
|
@@ -270,8 +285,15 @@ function generateAdvancedFields(idPrefix, options = {}) {
|
|
|
270
285
|
lines.push("<div id=\"" + idPrefix + "-advanced\" class=\"advanced-fields\">");
|
|
271
286
|
// Station ID.
|
|
272
287
|
const stationIdHint = showHints ? "Optional Gracenote station ID for guide data (tvc-guide-stationid)." : undefined;
|
|
273
|
-
|
|
274
|
-
|
|
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.
|
|
275
297
|
const guideProviderNames = getProviderModuleInfo().map((p) => p.label).sort().join(", ");
|
|
276
298
|
const channelSelectorHint = showHints ?
|
|
277
299
|
"Identifies which channel to select on sites that host multiple live streams. Known values are suggested when the URL matches a supported site. " +
|
|
@@ -279,23 +301,27 @@ function generateAdvancedFields(idPrefix, options = {}) {
|
|
|
279
301
|
"For tile and thumbnail profiles, right-click the channel element \u2192 Inspect \u2192 copy a unique value matching the profile's selector pattern " +
|
|
280
302
|
"(typically a portion of the image src URL)." :
|
|
281
303
|
undefined;
|
|
282
|
-
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 }));
|
|
283
305
|
// Channel number for Channels DVR and Plex integration.
|
|
284
306
|
const channelNumberHint = showHints ?
|
|
285
307
|
"Optional numeric channel number for guide matching in Channels DVR and Plex." :
|
|
286
308
|
undefined;
|
|
287
|
-
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 }));
|
|
288
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.
|
|
289
311
|
// This prevents users from entering tag names that don't exist in the managed vocabulary.
|
|
290
312
|
const vocabulary = getActiveTagVocabulary();
|
|
291
|
-
const currentTags =
|
|
313
|
+
const currentTags = tagsValue ? tagsValue.split(",").map((t) => t.trim()).filter((t) => t.length > 0) : [];
|
|
292
314
|
if (vocabulary.length > 0) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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 + ">");
|
|
296
322
|
lines.push("<div class=\"tag-checkbox-grid\">");
|
|
297
323
|
for (const tag of vocabulary) {
|
|
298
|
-
const checked = currentTags.
|
|
324
|
+
const checked = currentTags.some((t) => tagsMatch(t, tag)) ? " checked" : "";
|
|
299
325
|
lines.push("<label class=\"tag-checkbox-label\">" +
|
|
300
326
|
"<input type=\"checkbox\" class=\"tag-checkbox\" data-tag=\"" + escapeHtml(tag) + "\"" + checked +
|
|
301
327
|
" onchange=\"updateTagsHidden(this.closest('.form-row'))\">" +
|
|
@@ -310,11 +336,16 @@ function generateAdvancedFields(idPrefix, options = {}) {
|
|
|
310
336
|
// HDHomeRun lineup inclusion. A hidden input provides the "false" value when the checkbox is unchecked (unchecked checkboxes are not submitted in FormData).
|
|
311
337
|
// When checked, the checkbox value "true" overwrites the hidden input's "false" since it appears later in DOM order.
|
|
312
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) + "\"" : "";
|
|
313
344
|
const hdhrHint = showHints ? "When unchecked, this channel is hidden from the HDHomeRun lineup and not available in Plex." : undefined;
|
|
314
|
-
lines.push("<div class=\"form-row form-row-checkbox\">");
|
|
315
|
-
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>");
|
|
316
347
|
lines.push("<input type=\"hidden\" name=\"hdhrEnabled\" value=\"false\">");
|
|
317
|
-
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 + ">");
|
|
318
349
|
lines.push("</div>");
|
|
319
350
|
if (hdhrHint) {
|
|
320
351
|
lines.push("<div class=\"hint\">" + hdhrHint + "</div>");
|
|
@@ -324,8 +355,8 @@ function generateAdvancedFields(idPrefix, options = {}) {
|
|
|
324
355
|
}
|
|
325
356
|
/**
|
|
326
357
|
* Generates JavaScript variables for channel selector datalist population. Produces three variables: `channelSelectorsByDomain` maps URL hostnames to known
|
|
327
|
-
* channel selector values (from predefined channels and cached
|
|
328
|
-
* 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
|
|
329
360
|
* channels panel.
|
|
330
361
|
*
|
|
331
362
|
* @returns JavaScript variable declarations ready to embed in a `<script>` tag.
|
|
@@ -350,8 +381,8 @@ function generateChannelSelectorData() {
|
|
|
350
381
|
byDomain[hostname] ??= [];
|
|
351
382
|
byDomain[hostname].push({ label: channel.name ?? selector, stationId: channel.stationId, value: selector });
|
|
352
383
|
}
|
|
353
|
-
// Merge cached
|
|
354
|
-
// 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.
|
|
355
386
|
for (const provider of getCachedProviderChannels()) {
|
|
356
387
|
seen[provider.hostname] ??= new Set();
|
|
357
388
|
byDomain[provider.hostname] ??= [];
|
|
@@ -362,17 +393,18 @@ function generateChannelSelectorData() {
|
|
|
362
393
|
}
|
|
363
394
|
}
|
|
364
395
|
}
|
|
365
|
-
// Build the hostname→slug map and slug→guideUrl map for all
|
|
366
|
-
//
|
|
367
|
-
const
|
|
368
|
-
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();
|
|
369
400
|
// Sort entries within each domain alphabetically by label for consistent ordering in the datalist dropdown.
|
|
370
401
|
for (const entries of Object.values(byDomain)) {
|
|
371
402
|
entries.sort((a, b) => a.label.localeCompare(b.label));
|
|
372
403
|
}
|
|
373
404
|
return "var channelSelectorsByDomain = " + JSON.stringify(byDomain) + ";\n" +
|
|
374
|
-
"var
|
|
375
|
-
"var
|
|
405
|
+
"var serviceByDomain = " + JSON.stringify(serviceByDomain) + ";\n" +
|
|
406
|
+
"var serviceGuideUrl = " + JSON.stringify(serviceGuideUrl) + ";\n" +
|
|
407
|
+
"var predefinedByDomain = " + JSON.stringify(getPredefinedDomainMap()) + ";";
|
|
376
408
|
}
|
|
377
409
|
/**
|
|
378
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
|
|
@@ -424,7 +456,7 @@ export function generateTagManagerBody() {
|
|
|
424
456
|
"for playlist filtering and organization.</p>" +
|
|
425
457
|
"<div class=\"tag-manager-add\">" +
|
|
426
458
|
"<input type=\"text\" id=\"tag-manager-input\" placeholder=\"New tag name\" maxlength=\"30\" " +
|
|
427
|
-
"pattern=\"[a-
|
|
459
|
+
"pattern=\"[a-zA-Z0-9]([a-zA-Z0-9 -]*[a-zA-Z0-9])?\" onkeydown=\"if(event.key==='Enter'){event.preventDefault();createTag();}\">" +
|
|
428
460
|
"<button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createTag()\">Add</button>" +
|
|
429
461
|
"</div>" +
|
|
430
462
|
"<div id=\"tag-manager-error\" class=\"wizard-error\" style=\"display: none;\"></div>" +
|
|
@@ -452,13 +484,13 @@ function generateTagManagementModal() {
|
|
|
452
484
|
});
|
|
453
485
|
}
|
|
454
486
|
/**
|
|
455
|
-
* Generates the Browse Channels 2-step wizard modal. Step 1 is the
|
|
456
|
-
* (new, switch, current) is handled server-side by the annotated discovery response - no client-side matching logic is needed.
|
|
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
|
|
457
489
|
* embedded as JSON data blocks for the client-side wizard controller.
|
|
458
490
|
* @returns HTML string for the browse modal.
|
|
459
491
|
*/
|
|
460
492
|
function generateBrowseModal() {
|
|
461
|
-
// Build the
|
|
493
|
+
// Build the service info array from the provider module registry. The client uses this to render the service picker.
|
|
462
494
|
const providers = getProviderModuleInfo();
|
|
463
495
|
const guideUrls = getProviderGuideUrls();
|
|
464
496
|
return generateWizardModal({
|
|
@@ -469,26 +501,26 @@ function generateBrowseModal() {
|
|
|
469
501
|
],
|
|
470
502
|
contentId: "browse-content",
|
|
471
503
|
dataBlocks: [
|
|
472
|
-
"<script type=\"application/json\" id=\"browse-
|
|
504
|
+
"<script type=\"application/json\" id=\"browse-services-data\">" + JSON.stringify(providers) + "</script>",
|
|
473
505
|
"<script type=\"application/json\" id=\"browse-guide-urls-data\">" + JSON.stringify(guideUrls) + "</script>"
|
|
474
506
|
],
|
|
475
|
-
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.",
|
|
476
508
|
id: "browse-modal",
|
|
477
|
-
steps: ["
|
|
509
|
+
steps: ["Service", "Channels"],
|
|
478
510
|
title: "Browse Channels",
|
|
479
511
|
titleId: "browse-title"
|
|
480
512
|
});
|
|
481
513
|
}
|
|
482
514
|
/**
|
|
483
|
-
* Generates the
|
|
515
|
+
* Generates the Service Setup 3-step wizard modal. Steps: Services (multi-select), Sign In (sequential auth), Channels (summary + finish). The
|
|
484
516
|
* setupCompleted flag is embedded as a data attribute so the client can auto-show the wizard on first visit.
|
|
485
517
|
* @returns HTML string for the setup wizard modal.
|
|
486
518
|
*/
|
|
487
519
|
function generateSetupWizardModal() {
|
|
488
|
-
const enabled =
|
|
489
|
-
// Build
|
|
490
|
-
// (domain, iconUrl, displayName). The enabled field lets the client pre-check
|
|
491
|
-
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()
|
|
492
524
|
.filter((t) => t.tag !== "direct")
|
|
493
525
|
.map((t) => ({ ...t, enabled: enabled.includes(t.tag) }));
|
|
494
526
|
return generateWizardModal({
|
|
@@ -501,14 +533,14 @@ function generateSetupWizardModal() {
|
|
|
501
533
|
contentId: "setup-content",
|
|
502
534
|
dataAttributes: { "setup-completed": CONFIG.channels.setupCompleted ? "true" : "false" },
|
|
503
535
|
dataBlocks: [
|
|
504
|
-
"<script type=\"application/json\" id=\"setup-
|
|
536
|
+
"<script type=\"application/json\" id=\"setup-services-data\">" + JSON.stringify(tags) + "</script>"
|
|
505
537
|
],
|
|
506
|
-
description: "Set up your streaming
|
|
538
|
+
description: "Set up your streaming services in three steps: select your services, sign in, and choose your channels.",
|
|
507
539
|
errorId: "setup-error",
|
|
508
540
|
id: "setup-modal",
|
|
509
|
-
steps: ["
|
|
541
|
+
steps: ["Services", "Sign In", "Channels"],
|
|
510
542
|
stepsId: "setup-steps",
|
|
511
|
-
title: "
|
|
543
|
+
title: "Service Setup",
|
|
512
544
|
titleId: "setup-title"
|
|
513
545
|
});
|
|
514
546
|
}
|
|
@@ -534,60 +566,66 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
534
566
|
const isPredefined = isPredefinedChannel(key);
|
|
535
567
|
const isOverride = isPredefined && isUser;
|
|
536
568
|
const isDisabled = isPredefinedChannelDisabled(key);
|
|
537
|
-
const
|
|
538
|
-
// Check if this channel has multiple
|
|
539
|
-
const
|
|
540
|
-
// Build the
|
|
541
|
-
const
|
|
542
|
-
// 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.
|
|
543
577
|
const displayLines = [];
|
|
544
578
|
const rowClasses = [];
|
|
545
|
-
if (
|
|
579
|
+
if (isOverride) {
|
|
580
|
+
rowClasses.push("channel-override");
|
|
581
|
+
}
|
|
582
|
+
else if (isUser) {
|
|
546
583
|
rowClasses.push("user-channel");
|
|
547
584
|
}
|
|
548
585
|
if (isDisabled) {
|
|
549
586
|
rowClasses.push("channel-disabled");
|
|
550
587
|
}
|
|
551
|
-
if (!
|
|
588
|
+
if (!isAvailableByService) {
|
|
552
589
|
rowClasses.push("channel-unavailable");
|
|
553
590
|
}
|
|
554
591
|
const rowClassAttr = (rowClasses.length > 0) ? " class=\"" + rowClasses.join(" ") + "\"" : "";
|
|
592
|
+
const rowTitleAttr = isOverride ? " title=\"Customized from predefined defaults\"" : "";
|
|
555
593
|
// Compute effective tags early — used for both the row data attribute (tag column filter) and the Tags column cell rendering below.
|
|
556
594
|
const effectiveTags = getChannelEffectiveTags(channel);
|
|
557
595
|
const channelTagsAttr = (effectiveTags.length > 0) ? " data-channel-tags=\"" + escapeHtml(effectiveTags.join(",")) + "\"" : "";
|
|
558
|
-
displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + " data-provider-tags=\"" + escapeHtml(
|
|
596
|
+
displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + rowTitleAttr + " data-provider-tags=\"" + escapeHtml(serviceTags) +
|
|
559
597
|
"\"" + channelTagsAttr + ">");
|
|
560
598
|
displayLines.push("<td class=\"ch-key\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "key")) + "\">" + escapeHtml(key) + "</td>");
|
|
561
599
|
const channelLogoUrl = getChannelLogo(key) ?? "";
|
|
562
600
|
const logoAttr = channelLogoUrl ? " data-logo=\"" + escapeHtml(channelLogoUrl) + "\"" : "";
|
|
563
601
|
displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "name")) + "\"" + logoAttr +
|
|
564
602
|
"><span class=\"channel-name-cell\">" + escapeHtml(channel.name ?? key) + "</span></td>");
|
|
565
|
-
//
|
|
566
|
-
// label alongside the
|
|
567
|
-
displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "
|
|
568
|
-
const labelHidden =
|
|
569
|
-
const contentHidden =
|
|
570
|
-
displayLines.push("<em class=\"no-provider-label\"" + labelHidden + ">No available
|
|
571
|
-
if (
|
|
572
|
-
// Multi-
|
|
573
|
-
// 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()
|
|
574
612
|
// to remove them from the DOM.
|
|
575
|
-
const currentSelection =
|
|
613
|
+
const currentSelection = resolveServiceKey(key);
|
|
576
614
|
displayLines.push("<select class=\"provider-select\" data-channel=\"" + escapeHtml(key) +
|
|
577
|
-
"\" title=\"Choose which streaming
|
|
578
|
-
for (const variant of
|
|
615
|
+
"\" title=\"Choose which streaming service delivers this channel\" onchange=\"updateServiceSelection(this)\"" + contentHidden + ">");
|
|
616
|
+
for (const variant of serviceGroup.variants) {
|
|
579
617
|
const selected = (variant.key === currentSelection) ? " selected" : "";
|
|
580
|
-
const optionHidden = !
|
|
618
|
+
const optionHidden = !isServiceTagEnabled(variant.tag) ? " hidden" : "";
|
|
581
619
|
displayLines.push("<option value=\"" + escapeHtml(variant.key) + "\" data-provider-tag=\"" + escapeHtml(variant.tag) + "\"" + selected + optionHidden + ">" +
|
|
582
620
|
escapeHtml(variant.label) + "</option>");
|
|
583
621
|
}
|
|
584
622
|
displayLines.push("</select>");
|
|
585
623
|
}
|
|
586
624
|
else {
|
|
587
|
-
// Single-
|
|
588
|
-
// 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.
|
|
589
627
|
displayLines.push("<span class=\"provider-name\"" + contentHidden + ">" +
|
|
590
|
-
escapeHtml(
|
|
628
|
+
escapeHtml(getChannelServiceLabel(channel)) + "</span>");
|
|
591
629
|
}
|
|
592
630
|
displayLines.push("</td>");
|
|
593
631
|
// Optional columns: Number, HDHR, Station ID, Profile, Selector. All five are always rendered; visibility is controlled by CSS classes on the table element.
|
|
@@ -612,7 +650,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
612
650
|
else {
|
|
613
651
|
const resolved = getProfileForChannel(channel);
|
|
614
652
|
if (resolved.profileName !== "default") {
|
|
615
|
-
const label =
|
|
653
|
+
const label = getChannelServiceLabel(channel);
|
|
616
654
|
displayLines.push("<td class=\"col-profile\" data-sort-value=\"" + profileSortKey +
|
|
617
655
|
"\"><span class=\"text-muted\">" + escapeHtml(label + " (auto)") + "</span></td>");
|
|
618
656
|
}
|
|
@@ -634,14 +672,14 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
634
672
|
displayLines.push("<td>");
|
|
635
673
|
displayLines.push("<div class=\"btn-group\">");
|
|
636
674
|
const escapedKey = escapeHtml(key);
|
|
637
|
-
// Resolve the auth domain for the currently selected
|
|
638
|
-
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);
|
|
639
677
|
const authDomain = getAuthDomainForChannel(variantKey);
|
|
640
678
|
// Position 1: Edit (all channels).
|
|
641
679
|
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-edit\" title=\"Edit\" aria-label=\"Edit\" onclick=\"showEditForm('" + escapedKey +
|
|
642
680
|
"')\">" + ICON_EDIT + "</button>");
|
|
643
681
|
// Position 2: Login for enabled channels (with domain auth color), placeholder for disabled predefined. Custom channels (user-defined, not predefined) skip
|
|
644
|
-
// login coloring because they have no
|
|
682
|
+
// login coloring because they have no service concept.
|
|
645
683
|
if (!isDisabled) {
|
|
646
684
|
const authTimestamp = (isPredefined || isOverride) ? getDomainAuth(authDomain) : null;
|
|
647
685
|
const loginColorClass = authTimestamp ? " health-success" : "";
|
|
@@ -694,8 +732,8 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
694
732
|
displayLines.push("</td>");
|
|
695
733
|
displayLines.push("</tr>");
|
|
696
734
|
const displayRow = displayLines.join("\n");
|
|
697
|
-
// Generate edit form row for all channels. Pre-populate with the currently selected
|
|
698
|
-
// 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.
|
|
699
737
|
const editLines = [];
|
|
700
738
|
editLines.push("<tr id=\"edit-row-" + escapedKey + "\" style=\"display: none;\">");
|
|
701
739
|
editLines.push("<td colspan=\"" + String(TOTAL_COLUMN_COUNT) + "\">");
|
|
@@ -704,24 +742,42 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
704
742
|
editLines.push("<form id=\"edit-channel-form-" + escapedKey + "\" onsubmit=\"return submitChannelForm(event, 'edit')\">");
|
|
705
743
|
editLines.push("<input type=\"hidden\" name=\"action\" value=\"edit\">");
|
|
706
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;
|
|
707
750
|
// Channel name.
|
|
708
751
|
editLines.push(...generateTextField("edit-name-" + key, "name", "Display Name", channel.name ?? key, {
|
|
752
|
+
defaultValue: predefined ? (predefined.name ?? key) : undefined,
|
|
709
753
|
hint: "Friendly name shown in the playlist and UI.",
|
|
710
754
|
required: true
|
|
711
755
|
}));
|
|
712
756
|
// Channel URL.
|
|
713
757
|
editLines.push(...generateTextField("edit-url-" + key, "url", "Stream URL", channel.url, {
|
|
758
|
+
defaultValue: predefined?.url,
|
|
714
759
|
hint: "The URL of the streaming page to capture.",
|
|
715
760
|
required: true,
|
|
716
761
|
type: "url"
|
|
717
762
|
}));
|
|
718
763
|
// Profile dropdown.
|
|
719
|
-
editLines.push(...generateProfileDropdown("edit-profile-" + key, channel.profile ?? "", profiles));
|
|
764
|
+
editLines.push(...generateProfileDropdown("edit-profile-" + key, channel.profile ?? "", profiles, true, isOverride ? (predefined?.profile ?? "") : undefined));
|
|
720
765
|
// Advanced fields.
|
|
721
766
|
editLines.push(...generateAdvancedFields("edit-" + key, {
|
|
722
767
|
channelNumberValue: channel.channelNumber ? String(channel.channelNumber) : "",
|
|
723
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 ?? "",
|
|
724
779
|
hdhrEnabled: channel.hdhrEnabled !== false,
|
|
780
|
+
logoUrlValue: channel.logoUrl ?? "",
|
|
725
781
|
stationIdValue: channel.stationId ?? "",
|
|
726
782
|
tagsValue: effectiveTags.join(", ")
|
|
727
783
|
}));
|
|
@@ -760,7 +816,7 @@ export function buildChannelTableState(listing) {
|
|
|
760
816
|
else {
|
|
761
817
|
predefined++;
|
|
762
818
|
}
|
|
763
|
-
if (!entry.enabled || !entry.
|
|
819
|
+
if (!entry.enabled || !entry.availableByService) {
|
|
764
820
|
disabled++;
|
|
765
821
|
}
|
|
766
822
|
}
|
|
@@ -772,7 +828,7 @@ export function buildChannelTableState(listing) {
|
|
|
772
828
|
};
|
|
773
829
|
}
|
|
774
830
|
/**
|
|
775
|
-
* 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).
|
|
776
832
|
* @param listing - The channel listing entries.
|
|
777
833
|
* @returns An object with `enabled` (HDHR-included) and `total` counts.
|
|
778
834
|
*/
|
|
@@ -780,7 +836,7 @@ function getHdhrCounts(listing) {
|
|
|
780
836
|
let enabled = 0;
|
|
781
837
|
let total = 0;
|
|
782
838
|
for (const entry of listing) {
|
|
783
|
-
if (!entry.enabled || !entry.
|
|
839
|
+
if (!entry.enabled || !entry.availableByService) {
|
|
784
840
|
continue;
|
|
785
841
|
}
|
|
786
842
|
total++;
|
|
@@ -791,7 +847,7 @@ function getHdhrCounts(listing) {
|
|
|
791
847
|
return { enabled, total };
|
|
792
848
|
}
|
|
793
849
|
/**
|
|
794
|
-
* Computes per-tag channel counts for the Quick Actions tag bulk toggles. For each tag in the active vocabulary, counts how many enabled,
|
|
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
|
|
795
851
|
* channels have that tag in their effective tags. Returns undefined when the tags column is not visible (no tag toggles to update).
|
|
796
852
|
* @param listing - The channel listing entries.
|
|
797
853
|
* @returns A record mapping tag names to { count, total }, or undefined when hidden.
|
|
@@ -814,7 +870,7 @@ function getTagCounts(listing) {
|
|
|
814
870
|
counts.set(tag, 0);
|
|
815
871
|
}
|
|
816
872
|
for (const entry of listing) {
|
|
817
|
-
if (!entry.enabled || !entry.
|
|
873
|
+
if (!entry.enabled || !entry.availableByService) {
|
|
818
874
|
continue;
|
|
819
875
|
}
|
|
820
876
|
total++;
|
|
@@ -857,21 +913,21 @@ export function buildChannelTablePatch(affectedKeys, profiles) {
|
|
|
857
913
|
return { counts, hdhrCounts: getHdhrCounts(listing), rows, scopeCounts, tagCounts: getTagCounts(listing) };
|
|
858
914
|
}
|
|
859
915
|
/**
|
|
860
|
-
* Generates the
|
|
861
|
-
* @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.
|
|
862
918
|
*/
|
|
863
|
-
export function
|
|
864
|
-
const allTags =
|
|
865
|
-
const enabled =
|
|
919
|
+
export function generateServiceFilterToolbar() {
|
|
920
|
+
const allTags = getAllServiceTags();
|
|
921
|
+
const enabled = getEnabledServices();
|
|
866
922
|
const hasFilter = enabled.length > 0;
|
|
867
923
|
const lines = [];
|
|
868
924
|
lines.push("<div class=\"provider-toolbar\">");
|
|
869
|
-
//
|
|
925
|
+
// Service filter dropdown and chips. The button follows the toolbar dropdown pattern: icon + label + caret in a single button.
|
|
870
926
|
lines.push("<div class=\"toolbar-group\">");
|
|
871
927
|
lines.push("<div class=\"dropdown provider-dropdown\">");
|
|
872
|
-
const buttonText = hasFilter ? "Filtered" : "All
|
|
873
|
-
lines.push("<button type=\"button\" class=\"btn btn-sm toolbar-
|
|
874
|
-
"title=\"Filter channels by streaming
|
|
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>");
|
|
875
931
|
lines.push("<div class=\"dropdown-menu provider-dropdown-menu\">");
|
|
876
932
|
for (const tagInfo of allTags) {
|
|
877
933
|
const isDirectTag = tagInfo.tag === "direct";
|
|
@@ -880,7 +936,7 @@ export function generateProviderFilterToolbar() {
|
|
|
880
936
|
const disabledAttr = isDirectTag ? " disabled" : "";
|
|
881
937
|
lines.push("<label class=\"provider-option\">");
|
|
882
938
|
lines.push("<input type=\"checkbox\" data-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + checkedAttr + disabledAttr +
|
|
883
|
-
" onchange=\"
|
|
939
|
+
" onchange=\"toggleServiceTag(this)\"> " + serviceDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl));
|
|
884
940
|
lines.push("</label>");
|
|
885
941
|
}
|
|
886
942
|
lines.push("</div>");
|
|
@@ -895,9 +951,9 @@ export function generateProviderFilterToolbar() {
|
|
|
895
951
|
const chipTag = allTags.find((t) => t.tag === tag);
|
|
896
952
|
const displayName = chipTag?.displayName ?? tag;
|
|
897
953
|
lines.push("<span class=\"provider-chip\" data-tag=\"" + escapeHtml(tag) + "\">" +
|
|
898
|
-
|
|
954
|
+
serviceDisplaySpan(displayName, chipTag?.domain, chipTag?.iconUrl, true) +
|
|
899
955
|
"<button type=\"button\" class=\"chip-close\" title=\"Remove " + escapeHtml(displayName) + " from filter\" aria-label=\"Remove " +
|
|
900
|
-
escapeHtml(displayName) + "\" onclick=\"
|
|
956
|
+
escapeHtml(displayName) + "\" onclick=\"removeServiceChip('" + escapeHtml(tag) + "')\">×</button></span>");
|
|
901
957
|
}
|
|
902
958
|
}
|
|
903
959
|
lines.push("</div>");
|
|
@@ -916,17 +972,17 @@ export function generateProviderFilterToolbar() {
|
|
|
916
972
|
* @returns HTML string for the Channels panel content.
|
|
917
973
|
*/
|
|
918
974
|
export function generateChannelsPanel(channelMessage, channelError, editingChannelKey, showAddForm, formErrors, formValues) {
|
|
919
|
-
// Get the canonical channel listing (
|
|
920
|
-
// 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.
|
|
921
977
|
const listing = getChannelListing();
|
|
922
978
|
const profiles = getProfiles();
|
|
923
|
-
// Count channels hidden from the default view: disabled predefined channels OR channels with no available
|
|
924
|
-
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;
|
|
925
981
|
const lines = [];
|
|
926
982
|
// Panel description.
|
|
927
983
|
lines.push("<div class=\"settings-panel-description\">");
|
|
928
984
|
lines.push("<p>Define and manage streaming channels for the playlist. Customized channels are highlighted.</p>");
|
|
929
|
-
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>");
|
|
930
986
|
lines.push("</div>");
|
|
931
987
|
// Toolbar with three dropdown menus: Manage Channels (primary actions), Import/Export (data I/O), and Quick Actions (bulk operations). Each dropdown has an
|
|
932
988
|
// inline SVG icon + label + chevron for visual discoverability. Grouped menus reduce visual clutter and separate channel management from data I/O and bulk
|
|
@@ -935,7 +991,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
935
991
|
lines.push("<div class=\"toolbar-group\">");
|
|
936
992
|
// Manage Channels dropdown — primary channel creation and setup actions.
|
|
937
993
|
lines.push("<div class=\"dropdown\">");
|
|
938
|
-
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\" " +
|
|
939
995
|
"onclick=\"toggleDropdown(this)\">" + ICON_MANAGE + " Manage Channels ▾</button>");
|
|
940
996
|
lines.push("<div class=\"dropdown-menu\">");
|
|
941
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
|
|
@@ -951,32 +1007,32 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
951
1007
|
const TINTED_TAG = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#9b59b6\" stroke-width=\"1.5\" " +
|
|
952
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 " +
|
|
953
1009
|
"1 0 01-1.4 0L1.8 9.4a1 1 0 01-.3-.7z\"/><circle cx=\"5\" cy=\"5\" r=\"1\"/></svg>";
|
|
954
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
1010
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); " +
|
|
955
1011
|
"document.getElementById('add-channel-form').style.display='block';\">" + TINTED_ADD + " Add Channel</div>");
|
|
956
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
957
|
-
" Browse
|
|
958
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
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 +
|
|
959
1015
|
" Manage Tags</div>");
|
|
960
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
961
|
-
"
|
|
1016
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openSetupWizard()\">" + TINTED_SETUP +
|
|
1017
|
+
" Service Setup</div>");
|
|
962
1018
|
lines.push("</div>");
|
|
963
1019
|
lines.push("</div>");
|
|
964
1020
|
// Import / Export dropdown — data I/O operations.
|
|
965
1021
|
lines.push("<div class=\"dropdown\">");
|
|
966
|
-
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\" " +
|
|
967
1023
|
"onclick=\"toggleDropdown(this)\">" + ICON_TRANSFER + " Import / Export ▾</button>");
|
|
968
1024
|
lines.push("<div class=\"dropdown-menu\">");
|
|
969
1025
|
const TINTED_IMPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#5b8def\" stroke-width=\"1.5\" " +
|
|
970
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>";
|
|
971
1027
|
const TINTED_EXPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#22a563\" stroke-width=\"1.5\" " +
|
|
972
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>";
|
|
973
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
1029
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); document.getElementById('import-channels-file').click()\">" +
|
|
974
1030
|
TINTED_IMPORT + " Import Channels (JSON)</div>");
|
|
975
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
1031
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); document.getElementById('import-m3u-file').click()\">" +
|
|
976
1032
|
TINTED_IMPORT + " Import M3U Playlist</div>");
|
|
977
1033
|
lines.push("<label class=\"dropdown-option\"><input type=\"checkbox\" id=\"m3u-replace-duplicates\"> Replace duplicates on M3U import</label>");
|
|
978
1034
|
lines.push("<div class=\"dropdown-divider\"></div>");
|
|
979
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
1035
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); exportChannels()\">" +
|
|
980
1036
|
TINTED_EXPORT + " Export Channels (JSON)</div>");
|
|
981
1037
|
lines.push("</div>");
|
|
982
1038
|
lines.push("</div>");
|
|
@@ -985,7 +1041,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
985
1041
|
const visibleCols = new Set(CONFIG.channels.visibleColumns);
|
|
986
1042
|
// Quick Actions dropdown — bulk operations for predefined channels.
|
|
987
1043
|
lines.push("<div class=\"dropdown quick-actions-dropdown\">");
|
|
988
|
-
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\" " +
|
|
989
1045
|
"onclick=\"toggleDropdown(this)\">" + ICON_BOLT + " Quick Actions ▾</button>");
|
|
990
1046
|
lines.push("<div class=\"dropdown-menu\">");
|
|
991
1047
|
// Compute initial toggle counts for predefined channel scopes. The server is the single source of truth — the client renders what we return here.
|
|
@@ -1005,15 +1061,15 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1005
1061
|
"\">" + String(s.count) + " of " + String(s.total) + " enabled</span></label>");
|
|
1006
1062
|
}
|
|
1007
1063
|
lines.push("<div class=\"dropdown-divider\"></div>");
|
|
1008
|
-
// Bulk assign select — switch all multi-
|
|
1009
|
-
const allTags =
|
|
1010
|
-
const enabled =
|
|
1064
|
+
// Bulk assign select — switch all multi-service channels to a single service.
|
|
1065
|
+
const allTags = getAllServiceTags();
|
|
1066
|
+
const enabled = getEnabledServices();
|
|
1011
1067
|
const hasFilter = enabled.length > 0;
|
|
1012
1068
|
lines.push("<div class=\"bulk-assign-row\">");
|
|
1013
1069
|
lines.push("<span>Set all channels to:</span>");
|
|
1014
|
-
lines.push("<select id=\"bulk-assign-select\" class=\"bulk-assign-select\" title=\"Switch all multi-
|
|
1015
|
-
"onchange=\"if(this.value) {
|
|
1016
|
-
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>");
|
|
1017
1073
|
for (const tagInfo of allTags) {
|
|
1018
1074
|
const hidden = (hasFilter && !enabled.includes(tagInfo.tag) && (tagInfo.tag !== "direct")) ? " hidden" : "";
|
|
1019
1075
|
lines.push("<option value=\"" + escapeHtml(tagInfo.tag) + "\" data-provider-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + hidden + ">" +
|
|
@@ -1031,7 +1087,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1031
1087
|
lines.push("<span>Auto-number from:</span>");
|
|
1032
1088
|
lines.push("<input type=\"number\" id=\"auto-number-start\" class=\"auto-number-input\" value=\"1\" min=\"0\" max=\"99999\" " +
|
|
1033
1089
|
"placeholder=\"Clear\" onclick=\"event.stopPropagation()\">");
|
|
1034
|
-
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>");
|
|
1035
1091
|
lines.push("</div>");
|
|
1036
1092
|
lines.push("</div>");
|
|
1037
1093
|
// HDHR bulk toggle — tri-state checkbox to enable/disable all channels for the HDHomeRun lineup. Visible when the HDHR column is shown.
|
|
@@ -1051,8 +1107,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1051
1107
|
const tagsVisible = visibleCols.has("tags") ? "" : " style=\"display: none;\"";
|
|
1052
1108
|
const tagsVocabulary = getActiveTagVocabulary();
|
|
1053
1109
|
if (tagsVocabulary.length > 0) {
|
|
1054
|
-
// Count how many enabled,
|
|
1055
|
-
const enabledListing = listing.filter((entry) => entry.enabled && entry.
|
|
1110
|
+
// Count how many enabled, service-available channels have each tag.
|
|
1111
|
+
const enabledListing = listing.filter((entry) => entry.enabled && entry.availableByService);
|
|
1056
1112
|
const totalEnabled = enabledListing.length;
|
|
1057
1113
|
const tagCountMap = new Map();
|
|
1058
1114
|
for (const tag of tagsVocabulary) {
|
|
@@ -1131,12 +1187,12 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1131
1187
|
lines.push("<h3>Add New Channel</h3>");
|
|
1132
1188
|
lines.push("<form id=\"add-channel-form-el\" onsubmit=\"return submitChannelForm(event, 'add')\">");
|
|
1133
1189
|
lines.push("<input type=\"hidden\" name=\"action\" value=\"add\">");
|
|
1134
|
-
//
|
|
1135
|
-
// resolution). The pills use
|
|
1136
|
-
// guideUrl; non-guide-grid
|
|
1137
|
-
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();
|
|
1138
1194
|
const addFormGuideUrls = getProviderGuideUrls();
|
|
1139
|
-
lines.push("<div class=\"form-row\"><label>
|
|
1195
|
+
lines.push("<div class=\"form-row\"><label>Service</label>");
|
|
1140
1196
|
lines.push("<div class=\"provider-pills\">");
|
|
1141
1197
|
for (const tagInfo of addFormTags) {
|
|
1142
1198
|
if (tagInfo.tag === "direct") {
|
|
@@ -1144,11 +1200,11 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1144
1200
|
}
|
|
1145
1201
|
const pillUrl = addFormGuideUrls[tagInfo.tag] ?? (tagInfo.domain ? "https://" + tagInfo.domain + "/" : "");
|
|
1146
1202
|
lines.push("<button type=\"button\" class=\"provider-pill\" data-slug=\"" + escapeHtml(tagInfo.tag) + "\" data-url=\"" + escapeHtml(pillUrl) +
|
|
1147
|
-
"\" onclick=\"
|
|
1203
|
+
"\" onclick=\"selectServicePill(this)\">" + serviceDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl) + "</button>");
|
|
1148
1204
|
}
|
|
1149
1205
|
lines.push("</div>");
|
|
1150
1206
|
lines.push("</div>");
|
|
1151
|
-
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>");
|
|
1152
1208
|
// Channel key (add form only).
|
|
1153
1209
|
lines.push(...generateTextField("add-key", "key", "Channel Key", formValues?.get("key") ?? "", {
|
|
1154
1210
|
hint: "Lowercase letters, numbers, and hyphens only. Used in the URL: /stream/channel-key",
|
|
@@ -1169,9 +1225,12 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1169
1225
|
required: true,
|
|
1170
1226
|
type: "url"
|
|
1171
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>");
|
|
1172
1231
|
// Profile dropdown.
|
|
1173
1232
|
lines.push(...generateProfileDropdown("add-profile", formValues?.get("profile") ?? "", profiles));
|
|
1174
|
-
// Advanced fields (station ID, channel selector, channel number, HDHR).
|
|
1233
|
+
// Advanced fields (station ID, guide title, logo URL, channel selector, channel number, tags, HDHR).
|
|
1175
1234
|
lines.push(...generateAdvancedFields("add", {
|
|
1176
1235
|
channelNumberValue: formValues?.get("channelNumber") ?? "",
|
|
1177
1236
|
channelSelectorValue: formValues?.get("channelSelector") ?? "",
|
|
@@ -1197,8 +1256,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1197
1256
|
}
|
|
1198
1257
|
const sortField = CONFIG.channels.channelSortField;
|
|
1199
1258
|
const sortDir = CONFIG.channels.channelSortDirection;
|
|
1200
|
-
//
|
|
1201
|
-
lines.push(
|
|
1259
|
+
// Service filter toolbar with multi-select dropdown and chips.
|
|
1260
|
+
lines.push(generateServiceFilterToolbar());
|
|
1202
1261
|
// Channel summary line with predefined/user breakdown, placed directly above the table for visual proximity. The user-count span contains the entire user
|
|
1203
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.
|
|
1204
1263
|
const userPortion = (userCount > 0) ? ", " + String(userCount) + " user" : "";
|
|
@@ -1216,7 +1275,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1216
1275
|
const sortableHeaders = [
|
|
1217
1276
|
{ cssClass: "col-key", field: "key", label: "Key" },
|
|
1218
1277
|
{ cssClass: "col-name", field: "name", label: "Name" },
|
|
1219
|
-
{ cssClass: "col-provider", field: "provider", label: "
|
|
1278
|
+
{ cssClass: "col-provider", field: "provider", label: "Service" },
|
|
1220
1279
|
...OPTIONAL_COLUMNS
|
|
1221
1280
|
];
|
|
1222
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
|
|
@@ -1226,7 +1285,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1226
1285
|
const isActive = (sortField === hdr.field);
|
|
1227
1286
|
const activeIndicator = isActive ? ((sortDir === "asc") ? " ▲" : " ▼") : "";
|
|
1228
1287
|
lines.push("<th class=\"" + hdr.cssClass + " sortable\" data-sort-field=\"" + hdr.field +
|
|
1229
|
-
"\" onclick=\"
|
|
1288
|
+
"\" onclick=\"channelTable.sort('" + hdr.field + "')\">");
|
|
1230
1289
|
lines.push("<span class=\"sort-label\">" + hdr.label + activeIndicator + "</span>");
|
|
1231
1290
|
// Tags header: additional filter dropdown alongside the sort label. The dropdown is a client-side-only view filter (transient, not persisted) that
|
|
1232
1291
|
// shows/hides rows based on their data-channel-tags attribute. The dropdown content comes from generateTagFilterContent() — the single source of truth
|
|
@@ -1269,7 +1328,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1269
1328
|
lines.push("</thead>");
|
|
1270
1329
|
lines.push("<tbody>");
|
|
1271
1330
|
// Sort the listing by the user's preferred field and direction before rendering rows. The canonical getChannelListing() order is preserved for other callers.
|
|
1272
|
-
const sortedListing =
|
|
1331
|
+
const sortedListing = listing.toSorted((a, b) => compareChannelSort(a.channel, a.key, b.channel, b.key, sortField, sortDir));
|
|
1273
1332
|
// Generate rows for all channels using the shared row generator.
|
|
1274
1333
|
for (const entry of sortedListing) {
|
|
1275
1334
|
const rowHtml = generateChannelRowHtml(entry.key, profiles, entry);
|
|
@@ -1282,10 +1341,10 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1282
1341
|
// Embed channel selector data for datalist population. The client-side JavaScript uses this to offer known selector suggestions when the URL matches a
|
|
1283
1342
|
// multi-channel site like Disney+ or USA Network.
|
|
1284
1343
|
lines.push("<script>" + generateChannelSelectorData() + "</script>");
|
|
1285
|
-
// Browse Channels modal. The shell is server-rendered using wizard modal CSS classes. The content area (
|
|
1286
|
-
// 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.
|
|
1287
1346
|
lines.push(generateBrowseModal());
|
|
1288
|
-
//
|
|
1347
|
+
// Service Setup wizard modal. Follows the same wizard pattern as the service profile builder. Three steps: pick services, authenticate, browse channels.
|
|
1289
1348
|
// The setupCompleted flag is embedded as a data attribute so the client can auto-show the wizard on first visit.
|
|
1290
1349
|
lines.push(generateSetupWizardModal());
|
|
1291
1350
|
// Tag Management modal. A simple dialog for creating, deleting, and restoring organizational tags. Client-side handlers drive the CRUD operations via the
|