prismcast 1.6.0 → 1.8.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/dist/app.js +9 -8
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.js +1 -1
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +10 -34
- package/dist/browser/channelSelection.js +19 -96
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/display.d.ts +19 -0
- package/dist/browser/display.js +16 -11
- package/dist/browser/display.js.map +1 -1
- package/dist/browser/index.d.ts +9 -46
- package/dist/browser/index.js +136 -191
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/login.d.ts +65 -0
- package/dist/browser/login.js +196 -0
- package/dist/browser/login.js.map +1 -0
- package/dist/browser/precaching.js +12 -0
- package/dist/browser/precaching.js.map +1 -1
- package/dist/browser/tuning/comcastPolymer.d.ts +23 -0
- package/dist/browser/tuning/comcastPolymer.js +655 -0
- package/dist/browser/tuning/comcastPolymer.js.map +1 -0
- package/dist/browser/tuning/cox.d.ts +2 -0
- package/dist/browser/tuning/cox.js +14 -0
- package/dist/browser/tuning/cox.js.map +1 -0
- package/dist/browser/tuning/directv.js +1 -1
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.js +2 -2
- 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 +1 -1
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/shared.d.ts +46 -0
- package/dist/browser/tuning/shared.js +100 -0
- package/dist/browser/tuning/shared.js.map +1 -0
- package/dist/browser/tuning/sling.js +1 -1
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/browser/tuning/spectrum.js +1 -1
- package/dist/browser/tuning/spectrum.js.map +1 -1
- package/dist/browser/tuning/thumbnailRow.js +1 -1
- package/dist/browser/tuning/thumbnailRow.js.map +1 -1
- package/dist/browser/tuning/tileClick.js +1 -1
- package/dist/browser/tuning/tileClick.js.map +1 -1
- package/dist/browser/tuning/xfinity.js +10 -603
- package/dist/browser/tuning/xfinity.js.map +1 -1
- package/dist/browser/tuning/youtubeTv.js +1 -1
- package/dist/browser/tuning/youtubeTv.js.map +1 -1
- package/dist/browser/video.d.ts +18 -0
- package/dist/browser/video.js +69 -107
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.d.ts +4 -2
- package/dist/channels/index.js +2261 -800
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.js +30 -17
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +37 -63
- package/dist/config/index.js.map +1 -1
- package/dist/config/profiles.js +10 -10
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providerPacks.js +4 -1
- package/dist/config/providerPacks.js.map +1 -1
- package/dist/config/providers.d.ts +12 -5
- package/dist/config/providers.js +133 -104
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.js +15 -9
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +108 -7
- package/dist/config/userChannels.js +374 -44
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +3 -1
- package/dist/config/userConfig.js +31 -19
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.js +27 -39
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/channelMap.d.ts +1 -1
- package/dist/hdhr/channelMap.js +4 -4
- 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/index.js +9 -15
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +3 -0
- package/dist/native/index.js +16 -25
- package/dist/native/index.js.map +1 -1
- package/dist/native/probe.d.ts +4 -3
- package/dist/native/probe.js +36 -12
- package/dist/native/probe.js.map +1 -1
- package/dist/native/proxy.d.ts +2 -0
- package/dist/native/proxy.js +369 -326
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +26 -48
- package/dist/routes/assets.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 +43 -8
- package/dist/routes/components.js +122 -19
- package/dist/routes/components.js.map +1 -1
- package/dist/routes/config/channels/index.d.ts +2 -2
- 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 +749 -71
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +93 -2
- package/dist/routes/config/channels/table.js +594 -104
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/index.d.ts +1 -1
- package/dist/routes/config/index.js +1 -1
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/providers.d.ts +6 -6
- package/dist/routes/config/providers.js +76 -102
- package/dist/routes/config/providers.js.map +1 -1
- package/dist/routes/config/settings.js +57 -36
- package/dist/routes/config/settings.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.d.ts +0 -16
- package/dist/routes/index.js +0 -17
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/playlist.d.ts +6 -4
- package/dist/routes/playlist.js +68 -27
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/providers.js +106 -3
- package/dist/routes/providers.js.map +1 -1
- package/dist/routes/root/content.js +96 -40
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/index.js +2 -1
- package/dist/routes/root/index.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +955 -310
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +695 -569
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/index.d.ts +1 -0
- package/dist/routes/root/scripts/index.js +1 -0
- package/dist/routes/root/scripts/index.js.map +1 -1
- package/dist/routes/root/scripts/shared.d.ts +6 -0
- package/dist/routes/root/scripts/shared.js +437 -0
- package/dist/routes/root/scripts/shared.js.map +1 -0
- package/dist/routes/root/scripts/status.js +33 -24
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +157 -11
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/routes/ui.d.ts +1 -1
- package/dist/routes/ui.js +1 -1
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +4 -35
- package/dist/service/commands.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 -0
- package/dist/streaming/fmp4Segmenter.js +7 -4
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.js +274 -188
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.d.ts +9 -3
- package/dist/streaming/hlsResume.js +15 -8
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/hlsSegments.d.ts +1 -2
- package/dist/streaming/hlsSegments.js +48 -46
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/lifecycle.js +8 -12
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/monitor.js +625 -585
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mpegts.js +1 -1
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/preroll.d.ts +41 -20
- package/dist/streaming/preroll.js +157 -76
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/recovery.d.ts +0 -6
- package/dist/streaming/recovery.js +0 -8
- package/dist/streaming/recovery.js.map +1 -1
- package/dist/streaming/registry.d.ts +36 -9
- package/dist/streaming/registry.js +6 -9
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +3 -3
- package/dist/streaming/setup.js +38 -22
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.d.ts +7 -6
- package/dist/streaming/showInfo.js +156 -36
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +14 -2
- package/dist/streaming/statusEmitter.js +22 -4
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +34 -0
- package/dist/types/config.d.ts +3 -2
- package/dist/types/index.d.ts +3 -2
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +8 -3
- package/dist/types/selection.d.ts +1 -0
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +7 -15
- 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/cliOutput.d.ts +10 -0
- package/dist/utils/cliOutput.js +17 -0
- package/dist/utils/cliOutput.js.map +1 -0
- package/dist/utils/debugFilter.d.ts +0 -5
- package/dist/utils/debugFilter.js +5 -11
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/delay.d.ts +12 -0
- package/dist/utils/delay.js +20 -0
- package/dist/utils/delay.js.map +1 -1
- package/dist/utils/evaluate.js +6 -11
- package/dist/utils/evaluate.js.map +1 -1
- package/dist/utils/ffmpeg.d.ts +13 -5
- package/dist/utils/ffmpeg.js +32 -11
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/format.d.ts +20 -3
- package/dist/utils/format.js +32 -6
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logEmitter.js +1 -1
- package/dist/utils/logEmitter.js.map +1 -1
- package/dist/utils/retry.d.ts +15 -9
- package/dist/utils/retry.js +8 -26
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/timing.d.ts +5 -0
- package/dist/utils/timing.js +13 -0
- package/dist/utils/timing.js.map +1 -0
- package/dist/utils/version.js +2 -2
- package/dist/utils/version.js.map +1 -1
- package/package.json +5 -5
|
@@ -2,43 +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, getAllProviderTags, getAuthDomainForChannel, getChannelProviderLabel, getChannelProviderTags, getChannelSortKey, getEnabledProviders, getProviderGroup, hasMultipleProviders, isChannelAvailableByProvider, isProviderTagEnabled, resolvePredefinedVariant, resolveProviderKey } from "../../../config/providers.js";
|
|
6
7
|
import { escapeHtml, formatTimeAgo } from "../../../utils/index.js";
|
|
7
|
-
import {
|
|
8
|
+
import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getChannelLogo, getChannelsParseErrorMessage, getPredefinedScopeCounts, getTagRegistry, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel } from "../../../config/userChannels.js";
|
|
9
|
+
import { getCachedProviderChannels, getProviderDomainMap, getProviderGuideUrls, getProviderModuleInfo } from "../../../browser/channelSelection.js";
|
|
8
10
|
import { getChannelHealth, getDomainAuth } from "../../../config/health.js";
|
|
9
|
-
import { getChannelListing, 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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const ICON_COPY = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
|
|
31
|
-
"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>";
|
|
15
|
+
import { generateWizardModal } from "../../components.js";
|
|
16
|
+
/**
|
|
17
|
+
* Generates an annotated provider display span. The client-side page-load script processes these elements via providerIconHtml, 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 providerIconHtml path.
|
|
19
|
+
* @param name - The provider display name.
|
|
20
|
+
* @param domain - The provider's domain for icon fallback derivation. Undefined for providers without a known domain.
|
|
21
|
+
* @param iconUrl - Optional explicit icon URL to try before domain-derived fallbacks.
|
|
22
|
+
* @param small - When true, uses the small icon variant for chips.
|
|
23
|
+
* @returns HTML span string with data attributes for client-side processing.
|
|
24
|
+
*/
|
|
25
|
+
function providerDisplaySpan(name, domain, iconUrl, small) {
|
|
26
|
+
const domainAttr = domain ? " data-domain=\"" + escapeHtml(domain) + "\"" : "";
|
|
27
|
+
const iconAttr = iconUrl ? " data-icon-url=\"" + escapeHtml(iconUrl) + "\"" : "";
|
|
28
|
+
const sizeAttr = small ? " data-sm" : "";
|
|
29
|
+
return "<span class=\"provider-display\"" + domainAttr + iconAttr + sizeAttr + ">" + escapeHtml(name) + "</span>";
|
|
30
|
+
}
|
|
32
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
|
|
33
32
|
// column order in the table, slotted between Provider and Actions.
|
|
34
33
|
export const OPTIONAL_COLUMNS = [
|
|
35
34
|
{ align: "center", cssClass: "col-chnum", field: "channelNumber", label: "Number", width: "70px" },
|
|
35
|
+
{ align: "center", cssClass: "col-hdhr", field: "hdhrEnabled", label: "HDHR", width: "55px" },
|
|
36
36
|
{ align: "center", cssClass: "col-stationid", field: "stationId", label: "Station ID", width: "100px" },
|
|
37
37
|
{ align: "left", cssClass: "col-profile", field: "profile", label: "Profile", width: "130px" },
|
|
38
|
-
{ 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" }
|
|
39
40
|
];
|
|
40
|
-
// Total number of columns in the channels table (4 required +
|
|
41
|
-
const TOTAL_COLUMN_COUNT =
|
|
41
|
+
// Total number of columns in the channels table (4 required + 6 optional).
|
|
42
|
+
const TOTAL_COLUMN_COUNT = 10;
|
|
42
43
|
// Valid optional column field names.
|
|
43
44
|
export const VALID_OPTIONAL_COLUMNS = new Set(OPTIONAL_COLUMNS.map((c) => c.field));
|
|
44
45
|
/**
|
|
@@ -259,16 +260,8 @@ function generateProfileReference(profiles) {
|
|
|
259
260
|
lines.push("</div>");
|
|
260
261
|
return lines.join("\n");
|
|
261
262
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
* @param idPrefix - Prefix for element IDs ("add" or "edit").
|
|
265
|
-
* @param stationIdValue - Current station ID value.
|
|
266
|
-
* @param channelSelectorValue - Current channel selector value.
|
|
267
|
-
* @param channelNumberValue - Current channel number value.
|
|
268
|
-
* @param showHints - Whether to show hint text.
|
|
269
|
-
* @returns Array of HTML strings for the advanced fields section.
|
|
270
|
-
*/
|
|
271
|
-
function generateAdvancedFields(idPrefix, stationIdValue, channelSelectorValue, channelNumberValue, showHints = true) {
|
|
263
|
+
function generateAdvancedFields(idPrefix, options = {}) {
|
|
264
|
+
const { channelNumberValue = "", channelSelectorValue = "", hdhrEnabled = true, showHints = true, stationIdValue = "", tagsValue = "" } = options;
|
|
272
265
|
const lines = [];
|
|
273
266
|
// Advanced fields toggle.
|
|
274
267
|
lines.push("<div class=\"advanced-toggle\" onclick=\"document.getElementById('" + idPrefix +
|
|
@@ -278,10 +271,11 @@ function generateAdvancedFields(idPrefix, stationIdValue, channelSelectorValue,
|
|
|
278
271
|
// Station ID.
|
|
279
272
|
const stationIdHint = showHints ? "Optional Gracenote station ID for guide data (tvc-guide-stationid)." : undefined;
|
|
280
273
|
lines.push(...generateTextField(idPrefix + "-stationId", "stationId", "Station ID", stationIdValue, { hint: stationIdHint, placeholder: showHints ? "e.g., 12345" : undefined }));
|
|
281
|
-
// Channel selector.
|
|
274
|
+
// Channel selector. The guide-based provider list is derived from the provider module registry so it stays current as providers are added.
|
|
275
|
+
const guideProviderNames = getProviderModuleInfo().map((p) => p.label).sort().join(", ");
|
|
282
276
|
const channelSelectorHint = showHints ?
|
|
283
277
|
"Identifies which channel to select on sites that host multiple live streams. Known values are suggested when the URL matches a supported site. " +
|
|
284
|
-
"For guide-based profiles (
|
|
278
|
+
"For guide-based profiles (" + guideProviderNames + "), use the channel name or station code from the guide. " +
|
|
285
279
|
"For tile and thumbnail profiles, right-click the channel element \u2192 Inspect \u2192 copy a unique value matching the profile's selector pattern " +
|
|
286
280
|
"(typically a portion of the image src URL)." :
|
|
287
281
|
undefined;
|
|
@@ -291,6 +285,40 @@ function generateAdvancedFields(idPrefix, stationIdValue, channelSelectorValue,
|
|
|
291
285
|
"Optional numeric channel number for guide matching in Channels DVR and Plex." :
|
|
292
286
|
undefined;
|
|
293
287
|
lines.push(...generateTextField(idPrefix + "-channelNumber", "channelNumber", "Channel Number", channelNumberValue, { hint: channelNumberHint, placeholder: showHints ? "e.g., 501" : undefined }));
|
|
288
|
+
// 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
|
+
// This prevents users from entering tag names that don't exist in the managed vocabulary.
|
|
290
|
+
const vocabulary = getActiveTagVocabulary();
|
|
291
|
+
const currentTags = new Set(tagsValue ? tagsValue.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0) : []);
|
|
292
|
+
if (vocabulary.length > 0) {
|
|
293
|
+
lines.push("<div class=\"form-row\">");
|
|
294
|
+
lines.push("<label>Tags</label>");
|
|
295
|
+
lines.push("<input type=\"hidden\" name=\"tags\" id=\"" + idPrefix + "-tags-hidden\" value=\"" + escapeHtml(tagsValue) + "\">");
|
|
296
|
+
lines.push("<div class=\"tag-checkbox-grid\">");
|
|
297
|
+
for (const tag of vocabulary) {
|
|
298
|
+
const checked = currentTags.has(tag) ? " checked" : "";
|
|
299
|
+
lines.push("<label class=\"tag-checkbox-label\">" +
|
|
300
|
+
"<input type=\"checkbox\" class=\"tag-checkbox\" data-tag=\"" + escapeHtml(tag) + "\"" + checked +
|
|
301
|
+
" onchange=\"updateTagsHidden(this.closest('.form-row'))\">" +
|
|
302
|
+
"<span class=\"tag-badge\">" + escapeHtml(tag) + "</span></label>");
|
|
303
|
+
}
|
|
304
|
+
lines.push("</div>");
|
|
305
|
+
if (showHints) {
|
|
306
|
+
lines.push("<div class=\"hint\">Select tags for playlist filtering. Manage available tags via Manage Channels > Manage Tags.</div>");
|
|
307
|
+
}
|
|
308
|
+
lines.push("</div>");
|
|
309
|
+
}
|
|
310
|
+
// HDHomeRun lineup inclusion. A hidden input provides the "false" value when the checkbox is unchecked (unchecked checkboxes are not submitted in FormData).
|
|
311
|
+
// When checked, the checkbox value "true" overwrites the hidden input's "false" since it appears later in DOM order.
|
|
312
|
+
const hdhrChecked = hdhrEnabled ? " checked" : "";
|
|
313
|
+
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>");
|
|
316
|
+
lines.push("<input type=\"hidden\" name=\"hdhrEnabled\" value=\"false\">");
|
|
317
|
+
lines.push("<input type=\"checkbox\" id=\"" + idPrefix + "-hdhrEnabled\" name=\"hdhrEnabled\" value=\"true\"" + hdhrChecked + ">");
|
|
318
|
+
lines.push("</div>");
|
|
319
|
+
if (hdhrHint) {
|
|
320
|
+
lines.push("<div class=\"hint\">" + hdhrHint + "</div>");
|
|
321
|
+
}
|
|
294
322
|
lines.push("</div>"); // End advanced fields.
|
|
295
323
|
return lines;
|
|
296
324
|
}
|
|
@@ -305,18 +333,22 @@ function generateAdvancedFields(idPrefix, stationIdValue, channelSelectorValue,
|
|
|
305
333
|
function generateChannelSelectorData() {
|
|
306
334
|
const byDomain = {};
|
|
307
335
|
const seen = {};
|
|
308
|
-
for (const
|
|
309
|
-
if (!
|
|
336
|
+
for (const [key, raw] of Object.entries(PREDEFINED_CHANNELS)) {
|
|
337
|
+
if (!raw.channelSelector) {
|
|
310
338
|
continue;
|
|
311
339
|
}
|
|
340
|
+
// Use the resolved channel (with inheritance from the canonical) so stationId, name, and other inherited fields are populated correctly for variants.
|
|
341
|
+
// The channelSelector is captured from the raw entry (already validated as non-null) to avoid non-null assertions on the resolved channel's optional field.
|
|
342
|
+
const selector = raw.channelSelector;
|
|
343
|
+
const channel = resolvePredefinedVariant(key) ?? raw;
|
|
312
344
|
const hostname = new URL(channel.url).hostname;
|
|
313
345
|
seen[hostname] ??= new Set();
|
|
314
|
-
if (seen[hostname].has(
|
|
346
|
+
if (seen[hostname].has(selector)) {
|
|
315
347
|
continue;
|
|
316
348
|
}
|
|
317
|
-
seen[hostname].add(
|
|
349
|
+
seen[hostname].add(selector);
|
|
318
350
|
byDomain[hostname] ??= [];
|
|
319
|
-
byDomain[hostname].push({ label: channel.name ?? channel.
|
|
351
|
+
byDomain[hostname].push({ label: channel.name ?? selector, stationId: channel.stationId, value: selector });
|
|
320
352
|
}
|
|
321
353
|
// Merge cached provider-discovered channels into the domain map. Predefined entries take precedence — we only add discovered channels whose channelSelector
|
|
322
354
|
// value is not already present for that domain. This enriches the datalist with the full provider lineup when precaching or prior discovery has run.
|
|
@@ -342,6 +374,144 @@ function generateChannelSelectorData() {
|
|
|
342
374
|
"var providerByDomain = " + JSON.stringify(providerByDomain) + ";\n" +
|
|
343
375
|
"var providerGuideUrl = " + JSON.stringify(providerGuideUrl) + ";";
|
|
344
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* 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
|
|
379
|
+
* and for incremental updates after tag CRUD operations. The dropdown shell (button, dropdown-menu wrapper) is rendered by the header generator; this function
|
|
380
|
+
* provides only the inner content (checkbox labels + "Show All" item).
|
|
381
|
+
* @returns HTML string for the filter dropdown content.
|
|
382
|
+
*/
|
|
383
|
+
export function generateTagFilterContent() {
|
|
384
|
+
const vocabulary = getActiveTagVocabulary();
|
|
385
|
+
const lines = [];
|
|
386
|
+
for (const tag of vocabulary) {
|
|
387
|
+
lines.push("<label class=\"provider-option\" onclick=\"event.stopPropagation()\">" +
|
|
388
|
+
"<input type=\"checkbox\" class=\"tag-filter-checkbox\" data-tag=\"" + escapeHtml(tag) + "\" checked onchange=\"applyTagColumnFilter()\"> " +
|
|
389
|
+
escapeHtml(tag) + "</label>");
|
|
390
|
+
}
|
|
391
|
+
if (vocabulary.length > 0) {
|
|
392
|
+
lines.push("<div class=\"dropdown-divider\"></div>");
|
|
393
|
+
lines.push("<div class=\"dropdown-item\" id=\"tag-filter-toggle\" onclick=\"event.stopPropagation(); toggleTagColumnFilter()\">Show None</div>");
|
|
394
|
+
}
|
|
395
|
+
return lines.join("");
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* 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
|
|
399
|
+
* 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.
|
|
400
|
+
* @returns HTML string for the tag manager body (tag list, input field, deleted tags section).
|
|
401
|
+
*/
|
|
402
|
+
export function generateTagManagerBody() {
|
|
403
|
+
const vocabulary = getActiveTagVocabulary();
|
|
404
|
+
const registry = getTagRegistry();
|
|
405
|
+
// Build the tag list HTML. Each tag is a badge with a delete button and inline rename on click.
|
|
406
|
+
const tagListItems = [];
|
|
407
|
+
for (const tag of vocabulary) {
|
|
408
|
+
tagListItems.push("<div class=\"tag-manager-item\" data-tag=\"" + escapeHtml(tag) + "\">" +
|
|
409
|
+
"<span class=\"tag-badge tag-editable\" title=\"Click to rename\" onclick=\"startTagRename(this, '" + escapeHtml(tag) +
|
|
410
|
+
"')\">" + escapeHtml(tag) + "</span>" +
|
|
411
|
+
"<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete tag\" onclick=\"deleteTag('" + escapeHtml(tag) +
|
|
412
|
+
"')\">" + ICON_DELETE + "</button></div>");
|
|
413
|
+
}
|
|
414
|
+
// Deleted predefined tags section — show restore option for tags the user has deleted.
|
|
415
|
+
const deletedItems = [];
|
|
416
|
+
for (const tag of registry.deletedTags) {
|
|
417
|
+
deletedItems.push("<div class=\"tag-manager-item tag-deleted\" data-tag=\"" + escapeHtml(tag) + "\">" +
|
|
418
|
+
"<span class=\"tag-badge tag-badge-deleted\">" + escapeHtml(tag) + "</span> <span class=\"tag-annotation\">(deleted)</span>" +
|
|
419
|
+
"<button type=\"button\" class=\"btn-icon\" title=\"Restore tag\" onclick=\"restoreTag('" + escapeHtml(tag) +
|
|
420
|
+
"')\">" + ICON_REVERT + "</button></div>");
|
|
421
|
+
}
|
|
422
|
+
return "<div class=\"tag-manager\">" +
|
|
423
|
+
"<p class=\"wizard-hint\">Create and manage the tag vocabulary. Tags can be assigned to channels via the channel table " +
|
|
424
|
+
"for playlist filtering and organization.</p>" +
|
|
425
|
+
"<div class=\"tag-manager-add\">" +
|
|
426
|
+
"<input type=\"text\" id=\"tag-manager-input\" placeholder=\"New tag name\" maxlength=\"30\" " +
|
|
427
|
+
"pattern=\"[a-z0-9]([a-z0-9-]*[a-z0-9])?\" onkeydown=\"if(event.key==='Enter'){event.preventDefault();createTag();}\">" +
|
|
428
|
+
"<button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createTag()\">Add</button>" +
|
|
429
|
+
"</div>" +
|
|
430
|
+
"<div id=\"tag-manager-error\" class=\"wizard-error\" style=\"display: none;\"></div>" +
|
|
431
|
+
"<div id=\"tag-manager-list\" class=\"tag-manager-list\">" +
|
|
432
|
+
(tagListItems.length > 0 ? tagListItems.join("") : "<div class=\"empty-state-text\">No tags defined.</div>") +
|
|
433
|
+
"</div>" +
|
|
434
|
+
(deletedItems.length > 0 ? "<div class=\"tag-manager-section-label\">Deleted Predefined Tags</div>" +
|
|
435
|
+
"<div id=\"tag-manager-deleted\" class=\"tag-manager-list\">" + deletedItems.join("") + "</div>" : "") +
|
|
436
|
+
"</div>";
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Generates the Tag Management modal shell using the wizard modal infrastructure for consistent styling.
|
|
440
|
+
* @returns HTML string for the complete tag management modal.
|
|
441
|
+
*/
|
|
442
|
+
function generateTagManagementModal() {
|
|
443
|
+
return generateWizardModal({
|
|
444
|
+
body: generateTagManagerBody(),
|
|
445
|
+
buttons: [
|
|
446
|
+
{ label: "Done", onclick: "closeTagManager()", position: "right", variant: "primary" }
|
|
447
|
+
],
|
|
448
|
+
id: "tag-manager-modal",
|
|
449
|
+
maxWidth: "420px",
|
|
450
|
+
onClose: "closeTagManager()",
|
|
451
|
+
title: "Manage Tags"
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Generates the Browse Channels 2-step wizard modal. Step 1 is the provider picker grid, step 2 is channel discovery and management. Channel state detection
|
|
456
|
+
* (new, switch, current) is handled server-side by the annotated discovery response - no client-side matching logic is needed. Provider and guide URL data are
|
|
457
|
+
* embedded as JSON data blocks for the client-side wizard controller.
|
|
458
|
+
* @returns HTML string for the browse modal.
|
|
459
|
+
*/
|
|
460
|
+
function generateBrowseModal() {
|
|
461
|
+
// Build the provider info array from the provider module registry. The client uses this to render the provider picker.
|
|
462
|
+
const providers = getProviderModuleInfo();
|
|
463
|
+
const guideUrls = getProviderGuideUrls();
|
|
464
|
+
return generateWizardModal({
|
|
465
|
+
buttons: [
|
|
466
|
+
{ id: "browse-back", label: "Back", position: "left", role: "back", visible: false },
|
|
467
|
+
{ label: "Cancel", position: "right", role: "close" },
|
|
468
|
+
{ id: "browse-add-btn", label: "Apply", onclick: "submitBrowseChannels()", position: "right", variant: "primary", visible: false }
|
|
469
|
+
],
|
|
470
|
+
contentId: "browse-content",
|
|
471
|
+
dataBlocks: [
|
|
472
|
+
"<script type=\"application/json\" id=\"browse-providers-data\">" + JSON.stringify(providers) + "</script>",
|
|
473
|
+
"<script type=\"application/json\" id=\"browse-guide-urls-data\">" + JSON.stringify(guideUrls) + "</script>"
|
|
474
|
+
],
|
|
475
|
+
description: "Manage your channel lineup by provider. Add new channels, switch providers for existing channels, or remove channels you no longer need.",
|
|
476
|
+
id: "browse-modal",
|
|
477
|
+
steps: ["Provider", "Channels"],
|
|
478
|
+
title: "Browse Channels",
|
|
479
|
+
titleId: "browse-title"
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Generates the Provider Setup 3-step wizard modal. Steps: Providers (multi-select), Sign In (sequential auth), Channels (summary + finish). The
|
|
484
|
+
* setupCompleted flag is embedded as a data attribute so the client can auto-show the wizard on first visit.
|
|
485
|
+
* @returns HTML string for the setup wizard modal.
|
|
486
|
+
*/
|
|
487
|
+
function generateSetupWizardModal() {
|
|
488
|
+
const enabled = getEnabledProviders();
|
|
489
|
+
// Build provider data with enabled state from the current provider filter. getAllProviderTags is the single source of truth for provider metadata
|
|
490
|
+
// (domain, iconUrl, displayName). The enabled field lets the client pre-check providers on re-run without scraping DOM state.
|
|
491
|
+
const tags = getAllProviderTags()
|
|
492
|
+
.filter((t) => t.tag !== "direct")
|
|
493
|
+
.map((t) => ({ ...t, enabled: enabled.includes(t.tag) }));
|
|
494
|
+
return generateWizardModal({
|
|
495
|
+
buttons: [
|
|
496
|
+
{ id: "setup-back", label: "Back", position: "left", role: "back", visible: false },
|
|
497
|
+
{ id: "setup-skip", label: "Skip Setup", onclick: "skipSetup()", position: "right" },
|
|
498
|
+
{ id: "setup-next", label: "Next", position: "right", role: "next", variant: "primary" },
|
|
499
|
+
{ id: "setup-finish", label: "Finish", onclick: "finishSetup()", position: "right", variant: "primary", visible: false }
|
|
500
|
+
],
|
|
501
|
+
contentId: "setup-content",
|
|
502
|
+
dataAttributes: { "setup-completed": CONFIG.channels.setupCompleted ? "true" : "false" },
|
|
503
|
+
dataBlocks: [
|
|
504
|
+
"<script type=\"application/json\" id=\"setup-providers-data\">" + JSON.stringify(tags) + "</script>"
|
|
505
|
+
],
|
|
506
|
+
description: "Set up your streaming providers in three steps: select your services, sign in, and choose your channels.",
|
|
507
|
+
errorId: "setup-error",
|
|
508
|
+
id: "setup-modal",
|
|
509
|
+
steps: ["Providers", "Sign In", "Channels"],
|
|
510
|
+
stepsId: "setup-steps",
|
|
511
|
+
title: "Provider Setup",
|
|
512
|
+
titleId: "setup-title"
|
|
513
|
+
});
|
|
514
|
+
}
|
|
345
515
|
/**
|
|
346
516
|
* Generates the HTML for a single channel's table rows (display row and edit form row). All channels — predefined, override, and user-defined — get both rows.
|
|
347
517
|
* The edit form is pre-populated with the effective (resolved) values so users see what they're changing. When called from generateChannelsPanel() which already
|
|
@@ -382,9 +552,16 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
382
552
|
rowClasses.push("channel-unavailable");
|
|
383
553
|
}
|
|
384
554
|
const rowClassAttr = (rowClasses.length > 0) ? " class=\"" + rowClasses.join(" ") + "\"" : "";
|
|
385
|
-
|
|
555
|
+
// Compute effective tags early — used for both the row data attribute (tag column filter) and the Tags column cell rendering below.
|
|
556
|
+
const effectiveTags = getChannelEffectiveTags(channel);
|
|
557
|
+
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(providerTags) +
|
|
559
|
+
"\"" + channelTagsAttr + ">");
|
|
386
560
|
displayLines.push("<td class=\"ch-key\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "key")) + "\">" + escapeHtml(key) + "</td>");
|
|
387
|
-
|
|
561
|
+
const channelLogoUrl = getChannelLogo(key) ?? "";
|
|
562
|
+
const logoAttr = channelLogoUrl ? " data-logo=\"" + escapeHtml(channelLogoUrl) + "\"" : "";
|
|
563
|
+
displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "name")) + "\"" + logoAttr +
|
|
564
|
+
"><span class=\"channel-name-cell\">" + escapeHtml(channel.name ?? key) + "</span></td>");
|
|
388
565
|
// Provider column: dropdown for multi-provider channels, static provider name for single-provider. Both states always render a hidden "No available providers"
|
|
389
566
|
// label alongside the provider content so that client-side filterChannelRows() can toggle between them without a page reload.
|
|
390
567
|
displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "provider")) + "\">");
|
|
@@ -396,13 +573,12 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
396
573
|
// options get the hidden attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls filterChannelRows()
|
|
397
574
|
// to remove them from the DOM.
|
|
398
575
|
const currentSelection = resolveProviderKey(key);
|
|
399
|
-
displayLines.push("<select class=\"provider-select\" data-channel=\"" + escapeHtml(key) +
|
|
400
|
-
contentHidden + ">");
|
|
576
|
+
displayLines.push("<select class=\"provider-select\" data-channel=\"" + escapeHtml(key) +
|
|
577
|
+
"\" title=\"Choose which streaming provider delivers this channel\" onchange=\"updateProviderSelection(this)\"" + contentHidden + ">");
|
|
401
578
|
for (const variant of providerGroup.variants) {
|
|
402
579
|
const selected = (variant.key === currentSelection) ? " selected" : "";
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
displayLines.push("<option value=\"" + escapeHtml(variant.key) + "\" data-provider-tag=\"" + escapeHtml(tag) + "\"" + selected + optionHidden + ">" +
|
|
580
|
+
const optionHidden = !isProviderTagEnabled(variant.tag) ? " hidden" : "";
|
|
581
|
+
displayLines.push("<option value=\"" + escapeHtml(variant.key) + "\" data-provider-tag=\"" + escapeHtml(variant.tag) + "\"" + selected + optionHidden + ">" +
|
|
406
582
|
escapeHtml(variant.label) + "</option>");
|
|
407
583
|
}
|
|
408
584
|
displayLines.push("</select>");
|
|
@@ -414,11 +590,20 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
414
590
|
escapeHtml(getChannelProviderLabel(channel)) + "</span>");
|
|
415
591
|
}
|
|
416
592
|
displayLines.push("</td>");
|
|
417
|
-
// Optional columns: Number, Station ID, Profile, Selector. All
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
(channel.
|
|
593
|
+
// Optional columns: Number, HDHR, Station ID, Profile, Selector. All five are always rendered; visibility is controlled by CSS classes on the table element.
|
|
594
|
+
const cellKey = escapeHtml(key);
|
|
595
|
+
displayLines.push("<td class=\"col-chnum editable-cell\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "channelNumber")) +
|
|
596
|
+
"\" data-field=\"channelNumber\" data-key=\"" + cellKey + "\" data-value=\"" + (channel.channelNumber ? escapeHtml(String(channel.channelNumber)) : "") +
|
|
597
|
+
"\" onclick=\"startInlineEdit(this)\">" + (channel.channelNumber ? escapeHtml(String(channel.channelNumber)) : "<span class=\"text-muted\">–</span>") +
|
|
598
|
+
"</td>");
|
|
599
|
+
// HDHR column: inline checkbox for quick toggling. The checkbox submits changes via the toggleHdhr() client-side function.
|
|
600
|
+
const hdhrCheckedAttr = (channel.hdhrEnabled !== false) ? " checked" : "";
|
|
601
|
+
displayLines.push("<td class=\"col-hdhr\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "hdhrEnabled")) +
|
|
602
|
+
"\"><input type=\"checkbox\" data-key=\"" + cellKey + "\"" + hdhrCheckedAttr +
|
|
603
|
+
" onchange=\"toggleHdhr(this)\" title=\"Include in HDHomeRun/Plex lineup\"></td>");
|
|
604
|
+
displayLines.push("<td class=\"col-stationid editable-cell\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "stationId")) +
|
|
605
|
+
"\" data-field=\"stationId\" data-key=\"" + cellKey + "\" data-value=\"" + (channel.stationId ? escapeHtml(channel.stationId) : "") +
|
|
606
|
+
"\" onclick=\"startInlineEdit(this)\">" + (channel.stationId ? escapeHtml(channel.stationId) : "<span class=\"text-muted\">–</span>") + "</td>");
|
|
422
607
|
// Profile column: show explicit profile as-is, or the auto-resolved friendly name with "(auto)" suffix in muted style.
|
|
423
608
|
const profileSortKey = escapeHtml(getChannelSortKey(channel, key, "profile"));
|
|
424
609
|
if (channel.profile) {
|
|
@@ -437,6 +622,14 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
437
622
|
}
|
|
438
623
|
displayLines.push("<td class=\"col-selector\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "channelSelector")) + "\">" +
|
|
439
624
|
(channel.channelSelector ? escapeHtml(channel.channelSelector) : "<span class=\"text-muted\">–</span>") + "</td>");
|
|
625
|
+
// Tags column: render effective tags as pills. Clicking the cell opens a shared portal dropdown (rendered in <body>, positioned via getBoundingClientRect)
|
|
626
|
+
// for inline tag editing. The cell carries data-key and data-tags so the dropdown can populate the correct checked state.
|
|
627
|
+
const tagsHtml = (effectiveTags.length > 0) ?
|
|
628
|
+
effectiveTags.map((tag) => "<span class=\"tag-badge\">" + escapeHtml(tag) + "</span>").join(" ") :
|
|
629
|
+
"<span class=\"text-muted\">–</span>";
|
|
630
|
+
displayLines.push("<td class=\"col-tags editable-cell dropdown\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "tags")) +
|
|
631
|
+
"\" data-key=\"" + cellKey + "\" data-tags=\"" + escapeHtml(effectiveTags.join(",")) +
|
|
632
|
+
"\" onclick=\"toggleInlineTagDropdown(this)\">" + tagsHtml + "</td>");
|
|
440
633
|
// Actions column with icon buttons. Five positions per row: Edit (always), Login/placeholder, Health/placeholder, context-sensitive, and Copy URL.
|
|
441
634
|
displayLines.push("<td>");
|
|
442
635
|
displayLines.push("<div class=\"btn-group\">");
|
|
@@ -472,19 +665,9 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
472
665
|
else {
|
|
473
666
|
displayLines.push("<span class=\"btn-icon-placeholder\"></span>");
|
|
474
667
|
}
|
|
475
|
-
// Position 4:
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-revert\" title=\"Revert to defaults\" aria-label=\"Revert to defaults\"" +
|
|
479
|
-
" onclick=\"revertChannel('" + escapedKey + "')\">" + ICON_REVERT + "</button>");
|
|
480
|
-
}
|
|
481
|
-
else if (isUser && !isPredefined) {
|
|
482
|
-
// User-defined (not an override): delete.
|
|
483
|
-
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete\" aria-label=\"Delete\" onclick=\"deleteChannel('" +
|
|
484
|
-
escapedKey + "')\">" + ICON_DELETE + "</button>");
|
|
485
|
-
}
|
|
486
|
-
else if (isPredefined) {
|
|
487
|
-
// Predefined (no override): enable/disable toggle.
|
|
668
|
+
// Position 4: enable/disable toggle for predefined channels (regardless of override state), delete for user-defined channels. The enable/disable state and
|
|
669
|
+
// property overrides are independent concerns — a predefined channel can be disabled AND have customizations, and the toggle always reflects the visibility state.
|
|
670
|
+
if (isPredefined) {
|
|
488
671
|
if (isDisabled) {
|
|
489
672
|
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-enable\" title=\"Enable\" aria-label=\"Enable\" onclick=\"togglePredefinedChannel('" +
|
|
490
673
|
escapedKey + "', true)\">" + ICON_ENABLE + "</button>");
|
|
@@ -494,6 +677,10 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
494
677
|
escapedKey + "', false)\">" + ICON_DISABLE + "</button>");
|
|
495
678
|
}
|
|
496
679
|
}
|
|
680
|
+
else if (isUser) {
|
|
681
|
+
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete\" aria-label=\"Delete\" onclick=\"deleteChannel('" +
|
|
682
|
+
escapedKey + "')\">" + ICON_DELETE + "</button>");
|
|
683
|
+
}
|
|
497
684
|
// Position 5: Copy URL dropdown (all channels).
|
|
498
685
|
displayLines.push("<div class=\"dropdown copy-dropdown\">");
|
|
499
686
|
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-copy\" title=\"Copy stream URL\" aria-label=\"Copy stream URL\" " +
|
|
@@ -531,11 +718,21 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
531
718
|
// Profile dropdown.
|
|
532
719
|
editLines.push(...generateProfileDropdown("edit-profile-" + key, channel.profile ?? "", profiles));
|
|
533
720
|
// Advanced fields.
|
|
534
|
-
editLines.push(...generateAdvancedFields("edit-" + key,
|
|
721
|
+
editLines.push(...generateAdvancedFields("edit-" + key, {
|
|
722
|
+
channelNumberValue: channel.channelNumber ? String(channel.channelNumber) : "",
|
|
723
|
+
channelSelectorValue: channel.channelSelector ?? "",
|
|
724
|
+
hdhrEnabled: channel.hdhrEnabled !== false,
|
|
725
|
+
stationIdValue: channel.stationId ?? "",
|
|
726
|
+
tagsValue: effectiveTags.join(", ")
|
|
727
|
+
}));
|
|
535
728
|
// Form buttons.
|
|
536
729
|
editLines.push("<div class=\"form-buttons\">");
|
|
537
730
|
editLines.push("<button type=\"submit\" class=\"btn btn-primary\">Save Changes</button>");
|
|
538
731
|
editLines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"hideEditForm('" + escapedKey + "')\">Cancel</button>");
|
|
732
|
+
if (isOverride) {
|
|
733
|
+
editLines.push("<button type=\"button\" class=\"btn btn-secondary btn-revert\" onclick=\"revertChannel('" + escapedKey +
|
|
734
|
+
"')\">Revert to Defaults</button>");
|
|
735
|
+
}
|
|
539
736
|
editLines.push("</div>");
|
|
540
737
|
editLines.push("</form>");
|
|
541
738
|
editLines.push("</div>");
|
|
@@ -544,6 +741,121 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
544
741
|
const editRow = editLines.join("\n");
|
|
545
742
|
return { displayRow, editRow };
|
|
546
743
|
}
|
|
744
|
+
/**
|
|
745
|
+
* Computes the complete channel table state — summary counts and scope toggle counts — from the current channel listing. This is the server-side single source
|
|
746
|
+
* of truth for all count computation, replacing the client-side DOM-scanning approach. Called once per mutation as part of building a patch.
|
|
747
|
+
* @param listing - Optional pre-fetched listing. When omitted, fetches from getChannelListing(). Passing the listing avoids a redundant computation when
|
|
748
|
+
* buildChannelTablePatch already has it.
|
|
749
|
+
* @returns The channel table counts and scope toggle counts.
|
|
750
|
+
*/
|
|
751
|
+
export function buildChannelTableState(listing) {
|
|
752
|
+
listing ??= getChannelListing();
|
|
753
|
+
let predefined = 0;
|
|
754
|
+
let user = 0;
|
|
755
|
+
let disabled = 0;
|
|
756
|
+
for (const entry of listing) {
|
|
757
|
+
if ((entry.source === "user") || (entry.source === "override")) {
|
|
758
|
+
user++;
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
predefined++;
|
|
762
|
+
}
|
|
763
|
+
if (!entry.enabled || !entry.availableByProvider) {
|
|
764
|
+
disabled++;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const total = listing.length;
|
|
768
|
+
const enabled = total - disabled;
|
|
769
|
+
return {
|
|
770
|
+
counts: { disabled, enabled, predefined, total, user },
|
|
771
|
+
scopeCounts: getPredefinedScopeCounts()
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Counts channels included and excluded from the HDHomeRun lineup. Only counts enabled, provider-available channels (the same set visible in the table).
|
|
776
|
+
* @param listing - The channel listing entries.
|
|
777
|
+
* @returns An object with `enabled` (HDHR-included) and `total` counts.
|
|
778
|
+
*/
|
|
779
|
+
function getHdhrCounts(listing) {
|
|
780
|
+
let enabled = 0;
|
|
781
|
+
let total = 0;
|
|
782
|
+
for (const entry of listing) {
|
|
783
|
+
if (!entry.enabled || !entry.availableByProvider) {
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
total++;
|
|
787
|
+
if (entry.channel.hdhrEnabled !== false) {
|
|
788
|
+
enabled++;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return { enabled, total };
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Computes per-tag channel counts for the Quick Actions tag bulk toggles. For each tag in the active vocabulary, counts how many enabled, provider-available
|
|
795
|
+
* channels have that tag in their effective tags. Returns undefined when the tags column is not visible (no tag toggles to update).
|
|
796
|
+
* @param listing - The channel listing entries.
|
|
797
|
+
* @returns A record mapping tag names to { count, total }, or undefined when hidden.
|
|
798
|
+
*/
|
|
799
|
+
function getTagCounts(listing) {
|
|
800
|
+
const visibleCols = new Set(CONFIG.channels.visibleColumns);
|
|
801
|
+
if (!visibleCols.has("tags")) {
|
|
802
|
+
return undefined;
|
|
803
|
+
}
|
|
804
|
+
const vocabulary = getActiveTagVocabulary();
|
|
805
|
+
if (vocabulary.length === 0) {
|
|
806
|
+
return undefined;
|
|
807
|
+
}
|
|
808
|
+
const result = {};
|
|
809
|
+
let total = 0;
|
|
810
|
+
// Pre-compute the vocabulary Set once for the per-channel effective tags filter.
|
|
811
|
+
const vocabularySet = new Set(vocabulary);
|
|
812
|
+
const counts = new Map();
|
|
813
|
+
for (const tag of vocabulary) {
|
|
814
|
+
counts.set(tag, 0);
|
|
815
|
+
}
|
|
816
|
+
for (const entry of listing) {
|
|
817
|
+
if (!entry.enabled || !entry.availableByProvider) {
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
total++;
|
|
821
|
+
const effectiveTags = entry.channel.tags ? entry.channel.tags.filter((tag) => vocabularySet.has(tag)) : [];
|
|
822
|
+
for (const tag of effectiveTags) {
|
|
823
|
+
const current = counts.get(tag);
|
|
824
|
+
if (current !== undefined) {
|
|
825
|
+
counts.set(tag, current + 1);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
for (const tag of vocabulary) {
|
|
830
|
+
result[tag] = { count: counts.get(tag) ?? 0, total };
|
|
831
|
+
}
|
|
832
|
+
return result;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* 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
|
|
836
|
+
* remove actions for keys that no longer exist. Includes the current summary counts and scope toggle counts. This is the single function mutation endpoints
|
|
837
|
+
* call to construct their patch response.
|
|
838
|
+
* @param affectedKeys - The channel keys that were added, modified, or removed by the mutation.
|
|
839
|
+
* @param profiles - Available profiles for the edit form dropdown.
|
|
840
|
+
* @returns A complete ChannelTablePatch ready for client-side application.
|
|
841
|
+
*/
|
|
842
|
+
export function buildChannelTablePatch(affectedKeys, profiles) {
|
|
843
|
+
const listing = getChannelListing();
|
|
844
|
+
const listingByKey = new Map(listing.map((entry) => [entry.key, entry]));
|
|
845
|
+
const rows = [];
|
|
846
|
+
for (const key of affectedKeys) {
|
|
847
|
+
const entry = listingByKey.get(key);
|
|
848
|
+
if (entry) {
|
|
849
|
+
const rowHtml = generateChannelRowHtml(key, profiles, entry);
|
|
850
|
+
rows.push({ action: "update", displayRow: rowHtml.displayRow, editRow: rowHtml.editRow, key });
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
rows.push({ action: "remove", key });
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const { counts, scopeCounts } = buildChannelTableState(listing);
|
|
857
|
+
return { counts, hdhrCounts: getHdhrCounts(listing), rows, scopeCounts, tagCounts: getTagCounts(listing) };
|
|
858
|
+
}
|
|
547
859
|
/**
|
|
548
860
|
* Generates the provider filter toolbar HTML with a multi-select dropdown and dismissable chips.
|
|
549
861
|
* @returns HTML string for the provider filter toolbar.
|
|
@@ -554,12 +866,12 @@ export function generateProviderFilterToolbar() {
|
|
|
554
866
|
const hasFilter = enabled.length > 0;
|
|
555
867
|
const lines = [];
|
|
556
868
|
lines.push("<div class=\"provider-toolbar\">");
|
|
557
|
-
// Provider filter dropdown and chips.
|
|
869
|
+
// Provider filter dropdown and chips. The button follows the toolbar dropdown pattern: icon + label + caret in a single button.
|
|
558
870
|
lines.push("<div class=\"toolbar-group\">");
|
|
559
|
-
lines.push("<span class=\"toolbar-label\">Providers:</span>");
|
|
560
871
|
lines.push("<div class=\"dropdown provider-dropdown\">");
|
|
561
872
|
const buttonText = hasFilter ? "Filtered" : "All Providers";
|
|
562
|
-
lines.push("<button type=\"button\" class=\"btn btn-sm\" id=\"provider-filter-btn\"
|
|
873
|
+
lines.push("<button type=\"button\" class=\"btn btn-sm toolbar-dropdown-btn\" id=\"provider-filter-btn\" " +
|
|
874
|
+
"title=\"Filter channels by streaming provider\" onclick=\"toggleDropdown(this)\">" + ICON_FILTER + " " + buttonText + " ▾</button>");
|
|
563
875
|
lines.push("<div class=\"dropdown-menu provider-dropdown-menu\">");
|
|
564
876
|
for (const tagInfo of allTags) {
|
|
565
877
|
const isDirectTag = tagInfo.tag === "direct";
|
|
@@ -568,7 +880,7 @@ export function generateProviderFilterToolbar() {
|
|
|
568
880
|
const disabledAttr = isDirectTag ? " disabled" : "";
|
|
569
881
|
lines.push("<label class=\"provider-option\">");
|
|
570
882
|
lines.push("<input type=\"checkbox\" data-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + checkedAttr + disabledAttr +
|
|
571
|
-
" onchange=\"toggleProviderTag(this)\"> " +
|
|
883
|
+
" onchange=\"toggleProviderTag(this)\"> " + providerDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl));
|
|
572
884
|
lines.push("</label>");
|
|
573
885
|
}
|
|
574
886
|
lines.push("</div>");
|
|
@@ -580,10 +892,12 @@ export function generateProviderFilterToolbar() {
|
|
|
580
892
|
if (tag === "direct") {
|
|
581
893
|
continue;
|
|
582
894
|
}
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
895
|
+
const chipTag = allTags.find((t) => t.tag === tag);
|
|
896
|
+
const displayName = chipTag?.displayName ?? tag;
|
|
897
|
+
lines.push("<span class=\"provider-chip\" data-tag=\"" + escapeHtml(tag) + "\">" +
|
|
898
|
+
providerDisplaySpan(displayName, chipTag?.domain, chipTag?.iconUrl, true) +
|
|
899
|
+
"<button type=\"button\" class=\"chip-close\" title=\"Remove " + escapeHtml(displayName) + " from filter\" aria-label=\"Remove " +
|
|
900
|
+
escapeHtml(displayName) + "\" onclick=\"removeProviderChip('" + escapeHtml(tag) + "')\">×</button></span>");
|
|
587
901
|
}
|
|
588
902
|
}
|
|
589
903
|
lines.push("</div>");
|
|
@@ -614,29 +928,70 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
614
928
|
lines.push("<p>Define and manage streaming channels for the playlist. Customized channels are highlighted.</p>");
|
|
615
929
|
lines.push("<p class=\"description-hint\">Tip: Use the <strong>provider 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>provider dropdown</strong> on any multi-provider 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>");
|
|
616
930
|
lines.push("</div>");
|
|
617
|
-
// Toolbar with
|
|
931
|
+
// Toolbar with three dropdown menus: Manage Channels (primary actions), Import/Export (data I/O), and Quick Actions (bulk operations). Each dropdown has an
|
|
932
|
+
// inline SVG icon + label + chevron for visual discoverability. Grouped menus reduce visual clutter and separate channel management from data I/O and bulk
|
|
933
|
+
// operations.
|
|
618
934
|
lines.push("<div class=\"channel-toolbar\">");
|
|
619
|
-
// Channel operations. Import uses a dropdown menu to consolidate M3U and JSON import into a single button.
|
|
620
935
|
lines.push("<div class=\"toolbar-group\">");
|
|
621
|
-
|
|
936
|
+
// Manage Channels dropdown — primary channel creation and setup actions.
|
|
937
|
+
lines.push("<div class=\"dropdown\">");
|
|
938
|
+
lines.push("<button type=\"button\" class=\"btn btn-primary btn-sm toolbar-dropdown-btn\" title=\"Add, browse, or set up channels\" " +
|
|
939
|
+
"onclick=\"toggleDropdown(this)\">" + ICON_MANAGE + " Manage Channels ▾</button>");
|
|
940
|
+
lines.push("<div class=\"dropdown-menu\">");
|
|
941
|
+
// 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
|
|
942
|
+
// a glance. These are local to this function because the tint colors are context-specific to this dropdown.
|
|
943
|
+
const TINTED_ADD = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#22a563\" stroke-width=\"1.5\" " +
|
|
944
|
+
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 3v10M3 8h10\"/></svg>";
|
|
945
|
+
const TINTED_BROWSE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#5b8def\" stroke-width=\"1.5\" " +
|
|
946
|
+
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"2\" width=\"5\" height=\"5\" rx=\"1\"/><rect x=\"9\" y=\"2\" width=\"5\" " +
|
|
947
|
+
"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>";
|
|
948
|
+
const TINTED_SETUP = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#9b8cd8\" stroke-width=\"1.5\" " +
|
|
949
|
+
"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 " +
|
|
950
|
+
"3.1l1.4 1.4M11.5 11.5l1.4 1.4M3.1 12.9l1.4-1.4M11.5 4.5l1.4-1.4\"/></svg>";
|
|
951
|
+
const TINTED_TAG = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#9b59b6\" stroke-width=\"1.5\" " +
|
|
952
|
+
"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
|
+
"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=\"closeDropdowns(); " +
|
|
955
|
+
"document.getElementById('add-channel-form').style.display='block';\">" + TINTED_ADD + " Add Channel</div>");
|
|
956
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); openBrowseModal()\">" + TINTED_BROWSE +
|
|
957
|
+
" Browse Provider Channels</div>");
|
|
958
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); openTagManager()\">" + TINTED_TAG +
|
|
959
|
+
" Manage Tags</div>");
|
|
960
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); openSetupWizard()\">" + TINTED_SETUP +
|
|
961
|
+
" Provider Setup</div>");
|
|
962
|
+
lines.push("</div>");
|
|
963
|
+
lines.push("</div>");
|
|
964
|
+
// Import / Export dropdown — data I/O operations.
|
|
622
965
|
lines.push("<div class=\"dropdown\">");
|
|
623
|
-
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm\"
|
|
966
|
+
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-dropdown-btn\" title=\"Import or export channel data\" " +
|
|
967
|
+
"onclick=\"toggleDropdown(this)\">" + ICON_TRANSFER + " Import / Export ▾</button>");
|
|
624
968
|
lines.push("<div class=\"dropdown-menu\">");
|
|
625
|
-
|
|
969
|
+
const TINTED_IMPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#5b8def\" stroke-width=\"1.5\" " +
|
|
970
|
+
"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
|
+
const TINTED_EXPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#22a563\" stroke-width=\"1.5\" " +
|
|
972
|
+
"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=\"closeDropdowns(); document.getElementById('import-channels-file').click()\">" +
|
|
974
|
+
TINTED_IMPORT + " Import Channels (JSON)</div>");
|
|
975
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); document.getElementById('import-m3u-file').click()\">" +
|
|
976
|
+
TINTED_IMPORT + " Import M3U Playlist</div>");
|
|
977
|
+
lines.push("<label class=\"dropdown-option\"><input type=\"checkbox\" id=\"m3u-replace-duplicates\"> Replace duplicates on M3U import</label>");
|
|
626
978
|
lines.push("<div class=\"dropdown-divider\"></div>");
|
|
627
|
-
lines.push("<div class=\"dropdown-item\" onclick=\"closeDropdowns();
|
|
628
|
-
|
|
979
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"closeDropdowns(); exportChannels()\">" +
|
|
980
|
+
TINTED_EXPORT + " Export Channels (JSON)</div>");
|
|
629
981
|
lines.push("</div>");
|
|
630
982
|
lines.push("</div>");
|
|
631
|
-
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm\" onclick=\"exportChannels()\">Export</button>");
|
|
632
983
|
lines.push("<input type=\"file\" id=\"import-m3u-file\" accept=\".m3u,.m3u8\" style=\"display: none;\" onchange=\"importM3U(this)\">");
|
|
984
|
+
// Visible column set, used by Quick Actions to gate column-dependent options and by the table header to set initial hide classes.
|
|
985
|
+
const visibleCols = new Set(CONFIG.channels.visibleColumns);
|
|
986
|
+
// Quick Actions dropdown — bulk operations for predefined channels.
|
|
633
987
|
lines.push("<div class=\"dropdown quick-actions-dropdown\">");
|
|
634
|
-
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm\"
|
|
988
|
+
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-dropdown-btn\" title=\"Bulk operations for predefined channels\" " +
|
|
989
|
+
"onclick=\"toggleDropdown(this)\">" + ICON_BOLT + " Quick Actions ▾</button>");
|
|
635
990
|
lines.push("<div class=\"dropdown-menu\">");
|
|
636
991
|
// Compute initial toggle counts for predefined channel scopes. The server is the single source of truth — the client renders what we return here.
|
|
637
992
|
const scopeCounts = getPredefinedScopeCounts();
|
|
638
|
-
// Three toggle rows: checkbox + label +
|
|
639
|
-
//
|
|
993
|
+
// Three toggle rows: checkbox + label + count. Clicking toggles the group via bulkTogglePredefined(). The onclick uses event.preventDefault() to stop the
|
|
994
|
+
// native checkbox toggle — the server response drives the update.
|
|
640
995
|
const scopes = [
|
|
641
996
|
{ count: scopeCounts.all.enabled, label: "All Predefined", scope: "all", total: scopeCounts.all.total },
|
|
642
997
|
{ count: scopeCounts.east.enabled, label: "East Variants", scope: "east", total: scopeCounts.east.total },
|
|
@@ -645,18 +1000,88 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
645
1000
|
for (const s of scopes) {
|
|
646
1001
|
const checked = (s.count === s.total) ? " checked" : "";
|
|
647
1002
|
lines.push("<label class=\"provider-option\" onclick=\"event.preventDefault(); bulkTogglePredefined(!this.querySelector('input').checked, '" + s.scope +
|
|
648
|
-
"')\">" + "<input type=\"checkbox\" class=\"scope-toggle\" data-scope=\"" + s.scope + "\"
|
|
649
|
-
s.label + "<span class=\"quick-action-count\" data-scope=\"" + s.scope + "\"
|
|
1003
|
+
"')\">" + "<input type=\"checkbox\" class=\"scope-toggle\" data-scope=\"" + s.scope + "\"" + checked + "> " +
|
|
1004
|
+
s.label + "<span class=\"quick-action-count\" data-scope=\"" + s.scope + "\" data-enabled=\"" + String(s.count) + "\" data-total=\"" + String(s.total) +
|
|
1005
|
+
"\">" + String(s.count) + " of " + String(s.total) + " enabled</span></label>");
|
|
650
1006
|
}
|
|
651
1007
|
lines.push("<div class=\"dropdown-divider\"></div>");
|
|
652
|
-
// Bulk assign
|
|
1008
|
+
// Bulk assign select — switch all multi-provider channels to a single provider.
|
|
653
1009
|
const allTags = getAllProviderTags();
|
|
654
1010
|
const enabled = getEnabledProviders();
|
|
655
1011
|
const hasFilter = enabled.length > 0;
|
|
1012
|
+
lines.push("<div class=\"bulk-assign-row\">");
|
|
1013
|
+
lines.push("<span>Set all channels to:</span>");
|
|
1014
|
+
lines.push("<select id=\"bulk-assign-select\" class=\"bulk-assign-select\" title=\"Switch all multi-provider channels to this provider\" " +
|
|
1015
|
+
"onchange=\"if(this.value) { closeDropdowns(); bulkAssignProvider(this.value); this.value = ''; }\">");
|
|
1016
|
+
lines.push("<option value=\"\">Select provider</option>");
|
|
656
1017
|
for (const tagInfo of allTags) {
|
|
657
|
-
const hidden = (hasFilter && !enabled.includes(tagInfo.tag) && (tagInfo.tag !== "direct")) ? "
|
|
658
|
-
lines.push("<
|
|
659
|
-
|
|
1018
|
+
const hidden = (hasFilter && !enabled.includes(tagInfo.tag) && (tagInfo.tag !== "direct")) ? " hidden" : "";
|
|
1019
|
+
lines.push("<option value=\"" + escapeHtml(tagInfo.tag) + "\" data-provider-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + hidden + ">" +
|
|
1020
|
+
escapeHtml(tagInfo.displayName) + "</option>");
|
|
1021
|
+
}
|
|
1022
|
+
lines.push("</select>");
|
|
1023
|
+
lines.push("</div>");
|
|
1024
|
+
// Divider before column-gated quick actions. Hidden when neither the channel number nor HDHR column is visible.
|
|
1025
|
+
const dividerVisible = (visibleCols.has("channelNumber") || visibleCols.has("hdhrEnabled")) ? "" : " style=\"display: none;\"";
|
|
1026
|
+
lines.push("<div class=\"dropdown-divider\" id=\"quick-action-divider\"" + dividerVisible + "></div>");
|
|
1027
|
+
// Auto-number channels — assign sequential channel numbers to visible channels in current sort order. Visible when the channel number column is shown.
|
|
1028
|
+
const autoNumberVisible = visibleCols.has("channelNumber") ? "" : " style=\"display: none;\"";
|
|
1029
|
+
lines.push("<div id=\"quick-action-autonumber\"" + autoNumberVisible + ">");
|
|
1030
|
+
lines.push("<div class=\"bulk-assign-row\">");
|
|
1031
|
+
lines.push("<span>Auto-number from:</span>");
|
|
1032
|
+
lines.push("<input type=\"number\" id=\"auto-number-start\" class=\"auto-number-input\" value=\"1\" min=\"0\" max=\"99999\" " +
|
|
1033
|
+
"placeholder=\"Clear\" onclick=\"event.stopPropagation()\">");
|
|
1034
|
+
lines.push("<button type=\"button\" class=\"btn btn-sm btn-secondary\" onclick=\"closeDropdowns(); autoNumberChannels()\">Apply</button>");
|
|
1035
|
+
lines.push("</div>");
|
|
1036
|
+
lines.push("</div>");
|
|
1037
|
+
// HDHR bulk toggle — tri-state checkbox to enable/disable all channels for the HDHomeRun lineup. Visible when the HDHR column is shown.
|
|
1038
|
+
const hdhrVisible = visibleCols.has("hdhrEnabled") ? "" : " style=\"display: none;\"";
|
|
1039
|
+
const hdhrCounts = getHdhrCounts(listing);
|
|
1040
|
+
const hdhrCheckedAttr = (hdhrCounts.enabled === hdhrCounts.total) ? " checked" : "";
|
|
1041
|
+
const hdhrIndeterminate = ((hdhrCounts.enabled > 0) && (hdhrCounts.enabled < hdhrCounts.total)) ? " data-indeterminate=\"true\"" : "";
|
|
1042
|
+
lines.push("<div id=\"quick-action-hdhr\"" + hdhrVisible + ">");
|
|
1043
|
+
lines.push("<label class=\"provider-option\" onclick=\"event.preventDefault(); bulkToggleHdhr()\">");
|
|
1044
|
+
lines.push("<input type=\"checkbox\" id=\"hdhr-bulk-toggle\"" + hdhrCheckedAttr + hdhrIndeterminate + "> ");
|
|
1045
|
+
lines.push("Include all in HDHR/Plex");
|
|
1046
|
+
lines.push("<span class=\"quick-action-count\" id=\"hdhr-bulk-count\">" + String(hdhrCounts.enabled) + " of " + String(hdhrCounts.total) + "</span>");
|
|
1047
|
+
lines.push("</label>");
|
|
1048
|
+
lines.push("</div>");
|
|
1049
|
+
// 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
|
|
1050
|
+
// channels have the tag, and clicking it adds or removes the tag on all visible channels.
|
|
1051
|
+
const tagsVisible = visibleCols.has("tags") ? "" : " style=\"display: none;\"";
|
|
1052
|
+
const tagsVocabulary = getActiveTagVocabulary();
|
|
1053
|
+
if (tagsVocabulary.length > 0) {
|
|
1054
|
+
// Count how many enabled, provider-available channels have each tag.
|
|
1055
|
+
const enabledListing = listing.filter((entry) => entry.enabled && entry.availableByProvider);
|
|
1056
|
+
const totalEnabled = enabledListing.length;
|
|
1057
|
+
const tagCountMap = new Map();
|
|
1058
|
+
for (const tag of tagsVocabulary) {
|
|
1059
|
+
tagCountMap.set(tag, 0);
|
|
1060
|
+
}
|
|
1061
|
+
for (const entry of enabledListing) {
|
|
1062
|
+
const effectiveTags = getChannelEffectiveTags(entry.channel);
|
|
1063
|
+
for (const tag of effectiveTags) {
|
|
1064
|
+
const current = tagCountMap.get(tag);
|
|
1065
|
+
if (current !== undefined) {
|
|
1066
|
+
tagCountMap.set(tag, current + 1);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
lines.push("<div class=\"dropdown-divider\" id=\"quick-action-tags-divider\"" + tagsVisible + "></div>");
|
|
1071
|
+
lines.push("<div id=\"quick-action-tags\"" + tagsVisible + ">");
|
|
1072
|
+
lines.push("<div class=\"quick-action-section-label\">Tag Channels</div>");
|
|
1073
|
+
for (const tag of tagsVocabulary) {
|
|
1074
|
+
const count = tagCountMap.get(tag) ?? 0;
|
|
1075
|
+
const checkedAttr = (count === totalEnabled) ? " checked" : "";
|
|
1076
|
+
const indeterminate = ((count > 0) && (count < totalEnabled)) ? " data-indeterminate=\"true\"" : "";
|
|
1077
|
+
lines.push("<label class=\"provider-option\" onclick=\"event.preventDefault(); bulkToggleTag('" + escapeHtml(tag) +
|
|
1078
|
+
"', !this.querySelector('input').checked)\">" +
|
|
1079
|
+
"<input type=\"checkbox\" class=\"tag-bulk-toggle\" data-tag=\"" + escapeHtml(tag) + "\"" + checkedAttr + indeterminate + "> " +
|
|
1080
|
+
escapeHtml(tag) +
|
|
1081
|
+
"<span class=\"quick-action-count\" data-tag-count=\"" + escapeHtml(tag) + "\">" +
|
|
1082
|
+
String(count) + " of " + String(totalEnabled) + "</span></label>");
|
|
1083
|
+
}
|
|
1084
|
+
lines.push("</div>");
|
|
660
1085
|
}
|
|
661
1086
|
lines.push("</div>");
|
|
662
1087
|
lines.push("</div>");
|
|
@@ -706,6 +1131,24 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
706
1131
|
lines.push("<h3>Add New Channel</h3>");
|
|
707
1132
|
lines.push("<form id=\"add-channel-form-el\" onsubmit=\"return submitChannelForm(event, 'add')\">");
|
|
708
1133
|
lines.push("<input type=\"hidden\" name=\"action\" value=\"add\">");
|
|
1134
|
+
// Provider pills. Clicking a provider auto-fills the URL field, which triggers the existing URL-change infrastructure (datalist population, profile
|
|
1135
|
+
// resolution). The pills use providerDisplaySpan for icon rendering via the shared processProviderDisplays path. Guide-grid providers fill with their
|
|
1136
|
+
// guideUrl; non-guide-grid providers fill with https://{domain}/.
|
|
1137
|
+
const addFormTags = getAllProviderTags();
|
|
1138
|
+
const addFormGuideUrls = getProviderGuideUrls();
|
|
1139
|
+
lines.push("<div class=\"form-row\"><label>Provider</label>");
|
|
1140
|
+
lines.push("<div class=\"provider-pills\">");
|
|
1141
|
+
for (const tagInfo of addFormTags) {
|
|
1142
|
+
if (tagInfo.tag === "direct") {
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
const pillUrl = addFormGuideUrls[tagInfo.tag] ?? (tagInfo.domain ? "https://" + tagInfo.domain + "/" : "");
|
|
1146
|
+
lines.push("<button type=\"button\" class=\"provider-pill\" data-slug=\"" + escapeHtml(tagInfo.tag) + "\" data-url=\"" + escapeHtml(pillUrl) +
|
|
1147
|
+
"\" onclick=\"selectProviderPill(this)\">" + providerDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl) + "</button>");
|
|
1148
|
+
}
|
|
1149
|
+
lines.push("</div>");
|
|
1150
|
+
lines.push("</div>");
|
|
1151
|
+
lines.push("<div class=\"hint\">Select a provider to auto-fill the URL, or enter one manually below.</div>");
|
|
709
1152
|
// Channel key (add form only).
|
|
710
1153
|
lines.push(...generateTextField("add-key", "key", "Channel Key", formValues?.get("key") ?? "", {
|
|
711
1154
|
hint: "Lowercase letters, numbers, and hyphens only. Used in the URL: /stream/channel-key",
|
|
@@ -728,22 +1171,24 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
728
1171
|
}));
|
|
729
1172
|
// Profile dropdown.
|
|
730
1173
|
lines.push(...generateProfileDropdown("add-profile", formValues?.get("profile") ?? "", profiles));
|
|
731
|
-
// Advanced fields (station ID, channel selector, channel number).
|
|
732
|
-
lines.push(...generateAdvancedFields("add",
|
|
1174
|
+
// Advanced fields (station ID, channel selector, channel number, HDHR).
|
|
1175
|
+
lines.push(...generateAdvancedFields("add", {
|
|
1176
|
+
channelNumberValue: formValues?.get("channelNumber") ?? "",
|
|
1177
|
+
channelSelectorValue: formValues?.get("channelSelector") ?? "",
|
|
1178
|
+
hdhrEnabled: formValues?.get("hdhrEnabled") !== "false",
|
|
1179
|
+
stationIdValue: formValues?.get("stationId") ?? ""
|
|
1180
|
+
}));
|
|
733
1181
|
// Form buttons.
|
|
734
1182
|
lines.push("<div class=\"form-buttons\">");
|
|
735
1183
|
lines.push("<button type=\"submit\" class=\"btn btn-primary\">Add Channel</button>");
|
|
736
|
-
lines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"document.getElementById('add-channel-form').style.display='none'
|
|
1184
|
+
lines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"document.getElementById('add-channel-form').style.display='none';\">Cancel</button>");
|
|
737
1185
|
lines.push("</div>");
|
|
738
1186
|
lines.push("</form>");
|
|
739
1187
|
lines.push("</div>"); // End add-channel-form.
|
|
740
|
-
// Provider filter toolbar with multi-select dropdown and chips. Placed after the add channel form so the form flows directly from its trigger button.
|
|
741
|
-
lines.push(generateProviderFilterToolbar());
|
|
742
1188
|
// Profile reference section (hidden by default, toggled via link in profile dropdown hint).
|
|
743
1189
|
lines.push(generateProfileReference(profiles));
|
|
744
1190
|
// Channels table. Disabled predefined channels are hidden by default and revealed via the "Show disabled" toggle. The wrapper div enables horizontal scrolling on
|
|
745
1191
|
// small screens. Table classes dynamically include hide-col-* for each hidden optional column.
|
|
746
|
-
const visibleCols = new Set(CONFIG.channels.visibleColumns);
|
|
747
1192
|
const tableClasses = ["channel-table", "hide-disabled"];
|
|
748
1193
|
for (const col of OPTIONAL_COLUMNS) {
|
|
749
1194
|
if (!visibleCols.has(col.field)) {
|
|
@@ -752,13 +1197,17 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
752
1197
|
}
|
|
753
1198
|
const sortField = CONFIG.channels.channelSortField;
|
|
754
1199
|
const sortDir = CONFIG.channels.channelSortDirection;
|
|
755
|
-
//
|
|
756
|
-
|
|
1200
|
+
// Provider filter toolbar with multi-select dropdown and chips.
|
|
1201
|
+
lines.push(generateProviderFilterToolbar());
|
|
1202
|
+
// Channel summary line with predefined/user breakdown, placed directly above the table for visual proximity. The user-count span contains the entire user
|
|
1203
|
+
// portion (comma, count, and label) so the client can toggle it by setting textContent. When there are no user channels, the span is empty.
|
|
757
1204
|
const userPortion = (userCount > 0) ? ", " + String(userCount) + " user" : "";
|
|
758
1205
|
lines.push("<div class=\"channel-summary\"><span id=\"total-count\">" + String(totalCount) + "</span> channels " +
|
|
759
1206
|
"(<span id=\"predefined-count\">" + String(predefinedCount) + "</span> predefined<span id=\"user-count\">" + userPortion + "</span>) · " +
|
|
760
1207
|
"<span id=\"enabled-count\">" + String(enabledCount) + "</span> enabled · " +
|
|
761
1208
|
"<span id=\"disabled-count\">" + String(totalHiddenCount) + "</span> disabled</div>");
|
|
1209
|
+
// Channels table. The wrapper uses fit-content so it shrinks when columns are hidden, with max-width: 100% to prevent viewport overflow. Centered via auto
|
|
1210
|
+
// margins. Dropdown menus escape the container via portal to <body>.
|
|
762
1211
|
lines.push("<div class=\"channel-table-wrapper\">");
|
|
763
1212
|
lines.push("<table class=\"" + tableClasses.join(" ") + "\" data-sort-field=\"" + sortField + "\" data-sort-dir=\"" + sortDir + "\">");
|
|
764
1213
|
lines.push("<thead>");
|
|
@@ -770,13 +1219,36 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
770
1219
|
{ cssClass: "col-provider", field: "provider", label: "Provider" },
|
|
771
1220
|
...OPTIONAL_COLUMNS
|
|
772
1221
|
];
|
|
1222
|
+
// 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
|
|
1223
|
+
// .sort-label to modify only the label — never touching other children like the Tags filter dropdown. Clicking anywhere on the <th> triggers sort;
|
|
1224
|
+
// additional children (like the filter button) use event.stopPropagation() to prevent sort when interacting with them.
|
|
773
1225
|
for (const hdr of sortableHeaders) {
|
|
774
1226
|
const isActive = (sortField === hdr.field);
|
|
775
|
-
const
|
|
776
|
-
lines.push("<th class=\"" + hdr.cssClass + " sortable\" data-sort-field=\"" + hdr.field +
|
|
777
|
-
|
|
1227
|
+
const activeIndicator = isActive ? ((sortDir === "asc") ? " ▲" : " ▼") : "";
|
|
1228
|
+
lines.push("<th class=\"" + hdr.cssClass + " sortable\" data-sort-field=\"" + hdr.field +
|
|
1229
|
+
"\" onclick=\"sortChannelTable('" + hdr.field + "')\">");
|
|
1230
|
+
lines.push("<span class=\"sort-label\">" + hdr.label + activeIndicator + "</span>");
|
|
1231
|
+
// Tags header: additional filter dropdown alongside the sort label. The dropdown is a client-side-only view filter (transient, not persisted) that
|
|
1232
|
+
// shows/hides rows based on their data-channel-tags attribute. The dropdown content comes from generateTagFilterContent() — the single source of truth
|
|
1233
|
+
// shared with the tag CRUD incremental update path.
|
|
1234
|
+
if (hdr.field === "tags") {
|
|
1235
|
+
lines.push("<div class=\"dropdown tag-filter-dropdown\" style=\"display: inline;\">");
|
|
1236
|
+
lines.push("<button type=\"button\" class=\"btn-icon btn-tag-filter\" title=\"Filter by tag\" " +
|
|
1237
|
+
"onclick=\"event.stopPropagation(); toggleDropdown(this)\">" + ICON_FILTER + "</button>");
|
|
1238
|
+
lines.push("<div class=\"dropdown-menu\" id=\"tag-filter-menu\">" + generateTagFilterContent() + "</div>");
|
|
1239
|
+
lines.push("</div>");
|
|
1240
|
+
// Playlist hint icon. Hidden by default, shown by applyTagColumnFilter() when the filter is active. Clicking opens a popover with the playlist URL
|
|
1241
|
+
// that corresponds to the current tag filter, so users can copy it for Channels DVR configuration.
|
|
1242
|
+
lines.push("<div class=\"dropdown\" style=\"display: inline;\">");
|
|
1243
|
+
lines.push("<button type=\"button\" class=\"btn-icon btn-playlist-hint\" id=\"playlist-hint-btn\" " +
|
|
1244
|
+
"title=\"Playlist URL for this filter\" style=\"display: none;\" " +
|
|
1245
|
+
"onclick=\"event.stopPropagation(); showPlaylistHint(this)\">" + ICON_LINK + "</button>");
|
|
1246
|
+
lines.push("<div class=\"dropdown-menu playlist-hint-menu\"></div>");
|
|
1247
|
+
lines.push("</div>");
|
|
1248
|
+
}
|
|
1249
|
+
lines.push("</th>");
|
|
778
1250
|
}
|
|
779
|
-
// Actions header with table options dropdown.
|
|
1251
|
+
// Actions header with table options dropdown (kebab menu). Contains the show-disabled toggle and column visibility checkboxes.
|
|
780
1252
|
lines.push("<th class=\"col-actions\"><span>Actions</span>");
|
|
781
1253
|
lines.push("<div class=\"dropdown column-picker\">");
|
|
782
1254
|
lines.push("<button type=\"button\" class=\"btn-icon btn-col-picker\" title=\"Table options\" aria-label=\"Table options\" " +
|
|
@@ -810,6 +1282,24 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
810
1282
|
// Embed channel selector data for datalist population. The client-side JavaScript uses this to offer known selector suggestions when the URL matches a
|
|
811
1283
|
// multi-channel site like Disney+ or USA Network.
|
|
812
1284
|
lines.push("<script>" + generateChannelSelectorData() + "</script>");
|
|
1285
|
+
// Browse Channels modal. The shell is server-rendered using wizard modal CSS classes. The content area (provider picker, channel list) is rendered
|
|
1286
|
+
// client-side after fetching discovered channels from the provider endpoint.
|
|
1287
|
+
lines.push(generateBrowseModal());
|
|
1288
|
+
// Provider Setup wizard modal. Follows the same wizard pattern as the provider profile builder. Three steps: pick providers, authenticate, browse channels.
|
|
1289
|
+
// The setupCompleted flag is embedded as a data attribute so the client can auto-show the wizard on first visit.
|
|
1290
|
+
lines.push(generateSetupWizardModal());
|
|
1291
|
+
// Tag Management modal. A simple dialog for creating, deleting, and restoring organizational tags. Client-side handlers drive the CRUD operations via the
|
|
1292
|
+
// tag management API endpoints.
|
|
1293
|
+
lines.push(generateTagManagementModal());
|
|
1294
|
+
// 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;
|
|
1295
|
+
// populated dynamically from the clicked cell's data-tags attribute. Positioned via getBoundingClientRect() to escape the table wrapper's overflow:auto clipping.
|
|
1296
|
+
const vocabulary = getActiveTagVocabulary();
|
|
1297
|
+
lines.push("<div id=\"inline-tag-portal\" class=\"dropdown-menu inline-tag-menu\">");
|
|
1298
|
+
for (const tag of vocabulary) {
|
|
1299
|
+
lines.push("<label class=\"provider-option\" onclick=\"event.stopPropagation()\">" +
|
|
1300
|
+
"<input type=\"checkbox\" class=\"inline-tag-checkbox\" data-tag=\"" + escapeHtml(tag) + "\"> " + escapeHtml(tag) + "</label>");
|
|
1301
|
+
}
|
|
1302
|
+
lines.push("</div>");
|
|
813
1303
|
return lines.join("\n");
|
|
814
1304
|
}
|
|
815
1305
|
//# sourceMappingURL=table.js.map
|